From df0cd3023691aff3a03d365ef13ce90821e264fe Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 11 Apr 2021 06:09:10 -0700 Subject: Add support for VR videos through videojs-vr --- src/invidious/videos.cr | 4 ++++ src/invidious/views/components/player_sources.ecr | 4 ++++ src/invidious/views/watch.ecr | 3 ++- 3 files changed, 10 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index bf281507..061668a1 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -760,6 +760,10 @@ struct Video info["microformat"]?.try &.["playerMicroformatRenderer"]["isFamilySafe"]?.try &.as_bool || false end + def is_vr : Bool + info["streamingData"]?.try &.["adaptiveFormats"].as_a[0]?.try &.["projectionType"].as_s == "MESH" ? true : false || false + end + def wilson_score : Float64 ci_lower_bound(likes, likes + dislikes).round(4) end diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index a99fdbca..a146e5a9 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -4,7 +4,9 @@ + + @@ -12,6 +14,8 @@ + + <% if params.annotations %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 8b587eb3..baffa08b 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -57,7 +57,8 @@ we're going to need to do it here in order to allow for translations. "show_replies_text" => HTML.escape(translate(locale, "Show replies")), "params" => params, "preferences" => preferences, - "premiere_timestamp" => video.premiere_timestamp.try &.to_unix + "premiere_timestamp" => video.premiere_timestamp.try &.to_unix, + "vr" => video.is_vr }.to_pretty_json %> -- cgit v1.2.3 From 6e6f4d5a37b81b9717fa5488100eaf7f88eec9e5 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 11 Apr 2021 20:55:07 -0700 Subject: Allow configurable support of interactive 360 vid --- assets/js/player.js | 2 +- src/invidious/helpers/helpers.cr | 1 + src/invidious/routes/preferences.cr | 5 +++++ src/invidious/users.cr | 1 + src/invidious/videos.cr | 6 ++++++ src/invidious/views/components/player_sources.ecr | 7 +++++-- src/invidious/views/preferences.ecr | 4 ++++ 7 files changed, 23 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index 6ca8d185..0de18d92 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -149,7 +149,7 @@ player.on('error', function (event) { }); // Enable VR video support -if (video_data.vr) { +if (video_data.vr && video_data.params.vr_mode) { player.crossOrigin("anonymous") player.vr({projection: "EAC"}); } diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index e1d877b7..1f92c4ce 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -52,6 +52,7 @@ struct ConfigPreferences property video_loop : Bool = false property extend_desc : Bool = false property volume : Int32 = 100 + property vr_mode : Bool = true def to_tuple {% begin %} diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 4901d22b..e903aa6e 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -60,6 +60,10 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute volume = env.params.body["volume"]?.try &.as(String).to_i? volume ||= CONFIG.default_user_preferences.volume + vr_mode = env.params.body["vr_mode"]?.try &.as(String) + vr_mode ||= "off" + vr_mode = vr_mode == "on" + comments = [] of String 2.times do |i| comments << (env.params.body["comments[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.comments[i]) @@ -140,6 +144,7 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute unseen_only: unseen_only, video_loop: video_loop, volume: volume, + vr_mode: vr_mode }.to_json).to_json if user = env.get? "user" diff --git a/src/invidious/users.cr b/src/invidious/users.cr index e4ebb4d1..5dfd80bb 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -53,6 +53,7 @@ struct Preferences property latest_only : Bool = CONFIG.default_user_preferences.latest_only property listen : Bool = CONFIG.default_user_preferences.listen property local : Bool = CONFIG.default_user_preferences.local + property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode @[JSON::Field(converter: Preferences::ProcessString)] property locale : String = CONFIG.default_user_preferences.locale diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 061668a1..eff00e11 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -245,6 +245,7 @@ struct VideoPreferences property extend_desc : Bool property video_start : Float64 | Int32 property volume : Int32 + property vr_mode : Bool end struct Video @@ -1057,6 +1058,7 @@ def process_video_params(query, preferences) video_loop = query["loop"]?.try { |q| (q == "true" || q == "1").to_unsafe } extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } volume = query["volume"]?.try &.to_i? + vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } if preferences # region ||= preferences.region @@ -1076,6 +1078,7 @@ def process_video_params(query, preferences) video_loop ||= preferences.video_loop.to_unsafe extend_desc ||= preferences.extend_desc.to_unsafe volume ||= preferences.volume + vr_mode ||= preferences.vr_mode.to_unsafe end annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe @@ -1094,6 +1097,7 @@ def process_video_params(query, preferences) video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe volume ||= CONFIG.default_user_preferences.volume + vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe annotations = annotations == 1 autoplay = autoplay == 1 @@ -1104,6 +1108,7 @@ def process_video_params(query, preferences) related_videos = related_videos == 1 video_loop = video_loop == 1 extend_desc = extend_desc == 1 + vr_mode = vr_mode == 1 if CONFIG.disabled?("dash") && quality == "dash" quality = "high" @@ -1153,6 +1158,7 @@ def process_video_params(query, preferences) extend_desc: extend_desc, video_start: video_start, volume: volume, + vr_mode: vr_mode }) return params diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index a146e5a9..d3445ec3 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -4,7 +4,6 @@ - @@ -14,7 +13,6 @@ - <% if params.annotations %> @@ -26,3 +24,8 @@ <% end %> + +<% if params.vr_mode %> + + +<% end %> diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 602340a4..1e1e8cae 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -111,6 +111,10 @@ checked<% end %>> +
+ + checked<% end %>> +
<%= translate(locale, "Visual preferences") %> -- cgit v1.2.3 From a0fb75efcb14f95f44e427db9cae0d0c03ccfa2a Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 11 Apr 2021 21:14:05 -0700 Subject: Add licence for videojs-vr --- src/invidious/views/licenses.ecr | 14 ++++++++++++++ 1 file changed, 14 insertions(+) (limited to 'src') diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index c2ada992..9da14edc 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -233,6 +233,20 @@ + + + videojs-vr.js + + + + MIT + + + + <%= translate(locale, "source") %> + + + video.min.js -- cgit v1.2.3 From 5ba9a1f87d876464f5e7cf72777797c9772aebca Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 11 Apr 2021 21:34:56 -0700 Subject: Fix lint --- src/invidious/routes/preferences.cr | 2 +- src/invidious/videos.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index e903aa6e..815c1c70 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -144,7 +144,7 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute unseen_only: unseen_only, video_loop: video_loop, volume: volume, - vr_mode: vr_mode + vr_mode: vr_mode, }.to_json).to_json if user = env.get? "user" diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index eff00e11..116aafc7 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1158,7 +1158,7 @@ def process_video_params(query, preferences) extend_desc: extend_desc, video_start: video_start, volume: volume, - vr_mode: vr_mode + vr_mode: vr_mode, }) return params -- cgit v1.2.3 From f529948d8140ddb785ff6d6bf20c9ca5a0e204b8 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 23 May 2021 08:51:24 -0700 Subject: Change videojs-vr to the unminified version --- assets/js/videojs-vr.js | 52730 ++++++++++++++++++++ assets/js/videojs-vr.min.js | 176 - src/invidious/views/components/player_sources.ecr | 2 +- src/invidious/views/licenses.ecr | 2 +- 4 files changed, 52732 insertions(+), 178 deletions(-) create mode 100644 assets/js/videojs-vr.js delete mode 100644 assets/js/videojs-vr.min.js (limited to 'src') diff --git a/assets/js/videojs-vr.js b/assets/js/videojs-vr.js new file mode 100644 index 00000000..f857f61d --- /dev/null +++ b/assets/js/videojs-vr.js @@ -0,0 +1,52730 @@ +/*! @name videojs-vr @version 1.7.1 @license Apache-2.0 */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('global/window'), require('global/document'), require('video.js')) : + typeof define === 'function' && define.amd ? define(['global/window', 'global/document', 'video.js'], factory) : + (global = global || self, global.videojsVr = factory(global.window, global.document, global.videojs)); +}(this, function (window$1, document$1, videojs) { 'use strict'; + + window$1 = window$1 && window$1.hasOwnProperty('default') ? window$1['default'] : window$1; + document$1 = document$1 && document$1.hasOwnProperty('default') ? document$1['default'] : document$1; + videojs = videojs && videojs.hasOwnProperty('default') ? videojs['default'] : videojs; + + function _assertThisInitialized(self) { + if (self === void 0) { + throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); + } + + return self; + } + + var assertThisInitialized = _assertThisInitialized; + + function _inheritsLoose(subClass, superClass) { + subClass.prototype = Object.create(superClass.prototype); + subClass.prototype.constructor = subClass; + subClass.__proto__ = superClass; + } + + var inheritsLoose = _inheritsLoose; + + var version = "1.7.1"; + + var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; + + function unwrapExports (x) { + return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; + } + + function createCommonjsModule(fn, module) { + return module = { exports: {} }, fn(module, module.exports), module.exports; + } + + var webvrPolyfill = createCommonjsModule(function (module, exports) { + /** + * @license + * webvr-polyfill + * Copyright (c) 2015-2017 Google + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + /** + * @license + * cardboard-vr-display + * Copyright (c) 2015-2017 Google + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + /** + * @license + * webvr-polyfill-dpdb + * Copyright (c) 2017 Google + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + /** + * @license + * wglu-preserve-state + * Copyright (c) 2016, Brandon Jones. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + + /** + * @license + * nosleep.js + * Copyright (c) 2017, Rich Tibbett + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + + (function (global, factory) { + module.exports = factory() ; + }(commonjsGlobal, (function () { + var commonjsGlobal$1 = typeof window !== 'undefined' ? window : typeof commonjsGlobal !== 'undefined' ? commonjsGlobal : typeof self !== 'undefined' ? self : {}; + + + + function unwrapExports (x) { + return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; + } + + function createCommonjsModule(fn, module) { + return module = { exports: {} }, fn(module, module.exports), module.exports; + } + + var race = function race(promises) { + if (Promise.race) { + return Promise.race(promises); + } + return new Promise(function (resolve, reject) { + for (var i = 0; i < promises.length; i++) { + promises[i].then(resolve, reject); + } + }); + }; + + var isMobile = function isMobile() { + return (/Android/i.test(navigator.userAgent) || /iPhone|iPad|iPod/i.test(navigator.userAgent) + ); + }; + var copyArray = function copyArray(source, dest) { + for (var i = 0, n = source.length; i < n; i++) { + dest[i] = source[i]; + } + }; + var extend = function extend(dest, src) { + for (var key in src) { + if (src.hasOwnProperty(key)) { + dest[key] = src[key]; + } + } + return dest; + }; + + var cardboardVrDisplay = createCommonjsModule(function (module, exports) { + /** + * @license + * cardboard-vr-display + * Copyright (c) 2015-2017 Google + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + /** + * @license + * gl-preserve-state + * Copyright (c) 2016, Brandon Jones. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + /** + * @license + * webvr-polyfill-dpdb + * Copyright (c) 2015-2017 Google + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + /** + * @license + * nosleep.js + * Copyright (c) 2017, Rich Tibbett + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + (function (global, factory) { + module.exports = factory(); + }(commonjsGlobal$1, (function () { var classCallCheck = function (instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } + }; + var createClass = function () { + function defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + return function (Constructor, protoProps, staticProps) { + if (protoProps) defineProperties(Constructor.prototype, protoProps); + if (staticProps) defineProperties(Constructor, staticProps); + return Constructor; + }; + }(); + var slicedToArray = function () { + function sliceIterator(arr, i) { + var _arr = []; + var _n = true; + var _d = false; + var _e = undefined; + try { + for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { + _arr.push(_s.value); + if (i && _arr.length === i) break; + } + } catch (err) { + _d = true; + _e = err; + } finally { + try { + if (!_n && _i["return"]) _i["return"](); + } finally { + if (_d) throw _e; + } + } + return _arr; + } + return function (arr, i) { + if (Array.isArray(arr)) { + return arr; + } else if (Symbol.iterator in Object(arr)) { + return sliceIterator(arr, i); + } else { + throw new TypeError("Invalid attempt to destructure non-iterable instance"); + } + }; + }(); + var MIN_TIMESTEP = 0.001; + var MAX_TIMESTEP = 1; + var base64 = function base64(mimeType, _base) { + return 'data:' + mimeType + ';base64,' + _base; + }; + var lerp = function lerp(a, b, t) { + return a + (b - a) * t; + }; + var isIOS = function () { + var isIOS = /iPad|iPhone|iPod/.test(navigator.platform); + return function () { + return isIOS; + }; + }(); + var isWebViewAndroid = function () { + var isWebViewAndroid = navigator.userAgent.indexOf('Version') !== -1 && navigator.userAgent.indexOf('Android') !== -1 && navigator.userAgent.indexOf('Chrome') !== -1; + return function () { + return isWebViewAndroid; + }; + }(); + var isSafari = function () { + var isSafari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent); + return function () { + return isSafari; + }; + }(); + var isFirefoxAndroid = function () { + var isFirefoxAndroid = navigator.userAgent.indexOf('Firefox') !== -1 && navigator.userAgent.indexOf('Android') !== -1; + return function () { + return isFirefoxAndroid; + }; + }(); + var getChromeVersion = function () { + var match = navigator.userAgent.match(/.*Chrome\/([0-9]+)/); + var value = match ? parseInt(match[1], 10) : null; + return function () { + return value; + }; + }(); + var isChromeWithoutDeviceMotion = function () { + var value = false; + if (getChromeVersion() === 65) { + var match = navigator.userAgent.match(/.*Chrome\/([0-9\.]*)/); + if (match) { + var _match$1$split = match[1].split('.'), + _match$1$split2 = slicedToArray(_match$1$split, 4), + major = _match$1$split2[0], + minor = _match$1$split2[1], + branch = _match$1$split2[2], + build = _match$1$split2[3]; + value = parseInt(branch, 10) === 3325 && parseInt(build, 10) < 148; + } + } + return function () { + return value; + }; + }(); + var isR7 = function () { + var isR7 = navigator.userAgent.indexOf('R7 Build') !== -1; + return function () { + return isR7; + }; + }(); + var isLandscapeMode = function isLandscapeMode() { + var rtn = window.orientation == 90 || window.orientation == -90; + return isR7() ? !rtn : rtn; + }; + var isTimestampDeltaValid = function isTimestampDeltaValid(timestampDeltaS) { + if (isNaN(timestampDeltaS)) { + return false; + } + if (timestampDeltaS <= MIN_TIMESTEP) { + return false; + } + if (timestampDeltaS > MAX_TIMESTEP) { + return false; + } + return true; + }; + var getScreenWidth = function getScreenWidth() { + return Math.max(window.screen.width, window.screen.height) * window.devicePixelRatio; + }; + var getScreenHeight = function getScreenHeight() { + return Math.min(window.screen.width, window.screen.height) * window.devicePixelRatio; + }; + var requestFullscreen = function requestFullscreen(element) { + if (isWebViewAndroid()) { + return false; + } + if (element.requestFullscreen) { + element.requestFullscreen(); + } else if (element.webkitRequestFullscreen) { + element.webkitRequestFullscreen(); + } else if (element.mozRequestFullScreen) { + element.mozRequestFullScreen(); + } else if (element.msRequestFullscreen) { + element.msRequestFullscreen(); + } else { + return false; + } + return true; + }; + var exitFullscreen = function exitFullscreen() { + if (document.exitFullscreen) { + document.exitFullscreen(); + } else if (document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } else { + return false; + } + return true; + }; + var getFullscreenElement = function getFullscreenElement() { + return document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement; + }; + var linkProgram = function linkProgram(gl, vertexSource, fragmentSource, attribLocationMap) { + var vertexShader = gl.createShader(gl.VERTEX_SHADER); + gl.shaderSource(vertexShader, vertexSource); + gl.compileShader(vertexShader); + var fragmentShader = gl.createShader(gl.FRAGMENT_SHADER); + gl.shaderSource(fragmentShader, fragmentSource); + gl.compileShader(fragmentShader); + var program = gl.createProgram(); + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + for (var attribName in attribLocationMap) { + gl.bindAttribLocation(program, attribLocationMap[attribName], attribName); + }gl.linkProgram(program); + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + return program; + }; + var getProgramUniforms = function getProgramUniforms(gl, program) { + var uniforms = {}; + var uniformCount = gl.getProgramParameter(program, gl.ACTIVE_UNIFORMS); + var uniformName = ''; + for (var i = 0; i < uniformCount; i++) { + var uniformInfo = gl.getActiveUniform(program, i); + uniformName = uniformInfo.name.replace('[0]', ''); + uniforms[uniformName] = gl.getUniformLocation(program, uniformName); + } + return uniforms; + }; + var orthoMatrix = function orthoMatrix(out, left, right, bottom, top, near, far) { + var lr = 1 / (left - right), + bt = 1 / (bottom - top), + nf = 1 / (near - far); + out[0] = -2 * lr; + out[1] = 0; + out[2] = 0; + out[3] = 0; + out[4] = 0; + out[5] = -2 * bt; + out[6] = 0; + out[7] = 0; + out[8] = 0; + out[9] = 0; + out[10] = 2 * nf; + out[11] = 0; + out[12] = (left + right) * lr; + out[13] = (top + bottom) * bt; + out[14] = (far + near) * nf; + out[15] = 1; + return out; + }; + var isMobile = function isMobile() { + var check = false; + (function (a) { + if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a) || /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0, 4))) check = true; + })(navigator.userAgent || navigator.vendor || window.opera); + return check; + }; + var extend = function extend(dest, src) { + for (var key in src) { + if (src.hasOwnProperty(key)) { + dest[key] = src[key]; + } + } + return dest; + }; + var safariCssSizeWorkaround = function safariCssSizeWorkaround(canvas) { + if (isIOS()) { + var width = canvas.style.width; + var height = canvas.style.height; + canvas.style.width = parseInt(width) + 1 + 'px'; + canvas.style.height = parseInt(height) + 'px'; + setTimeout(function () { + canvas.style.width = width; + canvas.style.height = height; + }, 100); + } + window.canvas = canvas; + }; + var frameDataFromPose = function () { + var piOver180 = Math.PI / 180.0; + var rad45 = Math.PI * 0.25; + function mat4_perspectiveFromFieldOfView(out, fov, near, far) { + var upTan = Math.tan(fov ? fov.upDegrees * piOver180 : rad45), + downTan = Math.tan(fov ? fov.downDegrees * piOver180 : rad45), + leftTan = Math.tan(fov ? fov.leftDegrees * piOver180 : rad45), + rightTan = Math.tan(fov ? fov.rightDegrees * piOver180 : rad45), + xScale = 2.0 / (leftTan + rightTan), + yScale = 2.0 / (upTan + downTan); + out[0] = xScale; + out[1] = 0.0; + out[2] = 0.0; + out[3] = 0.0; + out[4] = 0.0; + out[5] = yScale; + out[6] = 0.0; + out[7] = 0.0; + out[8] = -((leftTan - rightTan) * xScale * 0.5); + out[9] = (upTan - downTan) * yScale * 0.5; + out[10] = far / (near - far); + out[11] = -1.0; + out[12] = 0.0; + out[13] = 0.0; + out[14] = far * near / (near - far); + out[15] = 0.0; + return out; + } + function mat4_fromRotationTranslation(out, q, v) { + var x = q[0], + y = q[1], + z = q[2], + w = q[3], + x2 = x + x, + y2 = y + y, + z2 = z + z, + xx = x * x2, + xy = x * y2, + xz = x * z2, + yy = y * y2, + yz = y * z2, + zz = z * z2, + wx = w * x2, + wy = w * y2, + wz = w * z2; + out[0] = 1 - (yy + zz); + out[1] = xy + wz; + out[2] = xz - wy; + out[3] = 0; + out[4] = xy - wz; + out[5] = 1 - (xx + zz); + out[6] = yz + wx; + out[7] = 0; + out[8] = xz + wy; + out[9] = yz - wx; + out[10] = 1 - (xx + yy); + out[11] = 0; + out[12] = v[0]; + out[13] = v[1]; + out[14] = v[2]; + out[15] = 1; + return out; + } + function mat4_translate(out, a, v) { + var x = v[0], + y = v[1], + z = v[2], + a00, + a01, + a02, + a03, + a10, + a11, + a12, + a13, + a20, + a21, + a22, + a23; + if (a === out) { + out[12] = a[0] * x + a[4] * y + a[8] * z + a[12]; + out[13] = a[1] * x + a[5] * y + a[9] * z + a[13]; + out[14] = a[2] * x + a[6] * y + a[10] * z + a[14]; + out[15] = a[3] * x + a[7] * y + a[11] * z + a[15]; + } else { + a00 = a[0];a01 = a[1];a02 = a[2];a03 = a[3]; + a10 = a[4];a11 = a[5];a12 = a[6];a13 = a[7]; + a20 = a[8];a21 = a[9];a22 = a[10];a23 = a[11]; + out[0] = a00;out[1] = a01;out[2] = a02;out[3] = a03; + out[4] = a10;out[5] = a11;out[6] = a12;out[7] = a13; + out[8] = a20;out[9] = a21;out[10] = a22;out[11] = a23; + out[12] = a00 * x + a10 * y + a20 * z + a[12]; + out[13] = a01 * x + a11 * y + a21 * z + a[13]; + out[14] = a02 * x + a12 * y + a22 * z + a[14]; + out[15] = a03 * x + a13 * y + a23 * z + a[15]; + } + return out; + } + function mat4_invert(out, a) { + var a00 = a[0], + a01 = a[1], + a02 = a[2], + a03 = a[3], + a10 = a[4], + a11 = a[5], + a12 = a[6], + a13 = a[7], + a20 = a[8], + a21 = a[9], + a22 = a[10], + a23 = a[11], + a30 = a[12], + a31 = a[13], + a32 = a[14], + a33 = a[15], + b00 = a00 * a11 - a01 * a10, + b01 = a00 * a12 - a02 * a10, + b02 = a00 * a13 - a03 * a10, + b03 = a01 * a12 - a02 * a11, + b04 = a01 * a13 - a03 * a11, + b05 = a02 * a13 - a03 * a12, + b06 = a20 * a31 - a21 * a30, + b07 = a20 * a32 - a22 * a30, + b08 = a20 * a33 - a23 * a30, + b09 = a21 * a32 - a22 * a31, + b10 = a21 * a33 - a23 * a31, + b11 = a22 * a33 - a23 * a32, + det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06; + if (!det) { + return null; + } + det = 1.0 / det; + out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det; + out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det; + out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det; + out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det; + out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det; + out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det; + out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det; + out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det; + out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det; + out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det; + out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det; + out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det; + out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det; + out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det; + out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det; + out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det; + return out; + } + var defaultOrientation = new Float32Array([0, 0, 0, 1]); + var defaultPosition = new Float32Array([0, 0, 0]); + function updateEyeMatrices(projection, view, pose, fov, offset, vrDisplay) { + mat4_perspectiveFromFieldOfView(projection, fov || null, vrDisplay.depthNear, vrDisplay.depthFar); + var orientation = pose.orientation || defaultOrientation; + var position = pose.position || defaultPosition; + mat4_fromRotationTranslation(view, orientation, position); + if (offset) mat4_translate(view, view, offset); + mat4_invert(view, view); + } + return function (frameData, pose, vrDisplay) { + if (!frameData || !pose) return false; + frameData.pose = pose; + frameData.timestamp = pose.timestamp; + updateEyeMatrices(frameData.leftProjectionMatrix, frameData.leftViewMatrix, pose, vrDisplay._getFieldOfView("left"), vrDisplay._getEyeOffset("left"), vrDisplay); + updateEyeMatrices(frameData.rightProjectionMatrix, frameData.rightViewMatrix, pose, vrDisplay._getFieldOfView("right"), vrDisplay._getEyeOffset("right"), vrDisplay); + return true; + }; + }(); + var isInsideCrossOriginIFrame = function isInsideCrossOriginIFrame() { + var isFramed = window.self !== window.top; + var refOrigin = getOriginFromUrl(document.referrer); + var thisOrigin = getOriginFromUrl(window.location.href); + return isFramed && refOrigin !== thisOrigin; + }; + var getOriginFromUrl = function getOriginFromUrl(url) { + var domainIdx; + var protoSepIdx = url.indexOf("://"); + if (protoSepIdx !== -1) { + domainIdx = protoSepIdx + 3; + } else { + domainIdx = 0; + } + var domainEndIdx = url.indexOf('/', domainIdx); + if (domainEndIdx === -1) { + domainEndIdx = url.length; + } + return url.substring(0, domainEndIdx); + }; + var getQuaternionAngle = function getQuaternionAngle(quat) { + if (quat.w > 1) { + console.warn('getQuaternionAngle: w > 1'); + return 0; + } + var angle = 2 * Math.acos(quat.w); + return angle; + }; + var warnOnce = function () { + var observedWarnings = {}; + return function (key, message) { + if (observedWarnings[key] === undefined) { + console.warn('webvr-polyfill: ' + message); + observedWarnings[key] = true; + } + }; + }(); + var deprecateWarning = function deprecateWarning(deprecated, suggested) { + var alternative = suggested ? 'Please use ' + suggested + ' instead.' : ''; + warnOnce(deprecated, deprecated + ' has been deprecated. ' + 'This may not work on native WebVR displays. ' + alternative); + }; + function WGLUPreserveGLState(gl, bindings, callback) { + if (!bindings) { + callback(gl); + return; + } + var boundValues = []; + var activeTexture = null; + for (var i = 0; i < bindings.length; ++i) { + var binding = bindings[i]; + switch (binding) { + case gl.TEXTURE_BINDING_2D: + case gl.TEXTURE_BINDING_CUBE_MAP: + var textureUnit = bindings[++i]; + if (textureUnit < gl.TEXTURE0 || textureUnit > gl.TEXTURE31) { + console.error("TEXTURE_BINDING_2D or TEXTURE_BINDING_CUBE_MAP must be followed by a valid texture unit"); + boundValues.push(null, null); + break; + } + if (!activeTexture) { + activeTexture = gl.getParameter(gl.ACTIVE_TEXTURE); + } + gl.activeTexture(textureUnit); + boundValues.push(gl.getParameter(binding), null); + break; + case gl.ACTIVE_TEXTURE: + activeTexture = gl.getParameter(gl.ACTIVE_TEXTURE); + boundValues.push(null); + break; + default: + boundValues.push(gl.getParameter(binding)); + break; + } + } + callback(gl); + for (var i = 0; i < bindings.length; ++i) { + var binding = bindings[i]; + var boundValue = boundValues[i]; + switch (binding) { + case gl.ACTIVE_TEXTURE: + break; + case gl.ARRAY_BUFFER_BINDING: + gl.bindBuffer(gl.ARRAY_BUFFER, boundValue); + break; + case gl.COLOR_CLEAR_VALUE: + gl.clearColor(boundValue[0], boundValue[1], boundValue[2], boundValue[3]); + break; + case gl.COLOR_WRITEMASK: + gl.colorMask(boundValue[0], boundValue[1], boundValue[2], boundValue[3]); + break; + case gl.CURRENT_PROGRAM: + gl.useProgram(boundValue); + break; + case gl.ELEMENT_ARRAY_BUFFER_BINDING: + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, boundValue); + break; + case gl.FRAMEBUFFER_BINDING: + gl.bindFramebuffer(gl.FRAMEBUFFER, boundValue); + break; + case gl.RENDERBUFFER_BINDING: + gl.bindRenderbuffer(gl.RENDERBUFFER, boundValue); + break; + case gl.TEXTURE_BINDING_2D: + var textureUnit = bindings[++i]; + if (textureUnit < gl.TEXTURE0 || textureUnit > gl.TEXTURE31) + break; + gl.activeTexture(textureUnit); + gl.bindTexture(gl.TEXTURE_2D, boundValue); + break; + case gl.TEXTURE_BINDING_CUBE_MAP: + var textureUnit = bindings[++i]; + if (textureUnit < gl.TEXTURE0 || textureUnit > gl.TEXTURE31) + break; + gl.activeTexture(textureUnit); + gl.bindTexture(gl.TEXTURE_CUBE_MAP, boundValue); + break; + case gl.VIEWPORT: + gl.viewport(boundValue[0], boundValue[1], boundValue[2], boundValue[3]); + break; + case gl.BLEND: + case gl.CULL_FACE: + case gl.DEPTH_TEST: + case gl.SCISSOR_TEST: + case gl.STENCIL_TEST: + if (boundValue) { + gl.enable(binding); + } else { + gl.disable(binding); + } + break; + default: + console.log("No GL restore behavior for 0x" + binding.toString(16)); + break; + } + if (activeTexture) { + gl.activeTexture(activeTexture); + } + } + } + var glPreserveState = WGLUPreserveGLState; + var distortionVS = ['attribute vec2 position;', 'attribute vec3 texCoord;', 'varying vec2 vTexCoord;', 'uniform vec4 viewportOffsetScale[2];', 'void main() {', ' vec4 viewport = viewportOffsetScale[int(texCoord.z)];', ' vTexCoord = (texCoord.xy * viewport.zw) + viewport.xy;', ' gl_Position = vec4( position, 1.0, 1.0 );', '}'].join('\n'); + var distortionFS = ['precision mediump float;', 'uniform sampler2D diffuse;', 'varying vec2 vTexCoord;', 'void main() {', ' gl_FragColor = texture2D(diffuse, vTexCoord);', '}'].join('\n'); + function CardboardDistorter(gl, cardboardUI, bufferScale, dirtySubmitFrameBindings) { + this.gl = gl; + this.cardboardUI = cardboardUI; + this.bufferScale = bufferScale; + this.dirtySubmitFrameBindings = dirtySubmitFrameBindings; + this.ctxAttribs = gl.getContextAttributes(); + this.meshWidth = 20; + this.meshHeight = 20; + this.bufferWidth = gl.drawingBufferWidth; + this.bufferHeight = gl.drawingBufferHeight; + this.realBindFramebuffer = gl.bindFramebuffer; + this.realEnable = gl.enable; + this.realDisable = gl.disable; + this.realColorMask = gl.colorMask; + this.realClearColor = gl.clearColor; + this.realViewport = gl.viewport; + if (!isIOS()) { + this.realCanvasWidth = Object.getOwnPropertyDescriptor(gl.canvas.__proto__, 'width'); + this.realCanvasHeight = Object.getOwnPropertyDescriptor(gl.canvas.__proto__, 'height'); + } + this.isPatched = false; + this.lastBoundFramebuffer = null; + this.cullFace = false; + this.depthTest = false; + this.blend = false; + this.scissorTest = false; + this.stencilTest = false; + this.viewport = [0, 0, 0, 0]; + this.colorMask = [true, true, true, true]; + this.clearColor = [0, 0, 0, 0]; + this.attribs = { + position: 0, + texCoord: 1 + }; + this.program = linkProgram(gl, distortionVS, distortionFS, this.attribs); + this.uniforms = getProgramUniforms(gl, this.program); + this.viewportOffsetScale = new Float32Array(8); + this.setTextureBounds(); + this.vertexBuffer = gl.createBuffer(); + this.indexBuffer = gl.createBuffer(); + this.indexCount = 0; + this.renderTarget = gl.createTexture(); + this.framebuffer = gl.createFramebuffer(); + this.depthStencilBuffer = null; + this.depthBuffer = null; + this.stencilBuffer = null; + if (this.ctxAttribs.depth && this.ctxAttribs.stencil) { + this.depthStencilBuffer = gl.createRenderbuffer(); + } else if (this.ctxAttribs.depth) { + this.depthBuffer = gl.createRenderbuffer(); + } else if (this.ctxAttribs.stencil) { + this.stencilBuffer = gl.createRenderbuffer(); + } + this.patch(); + this.onResize(); + } + CardboardDistorter.prototype.destroy = function () { + var gl = this.gl; + this.unpatch(); + gl.deleteProgram(this.program); + gl.deleteBuffer(this.vertexBuffer); + gl.deleteBuffer(this.indexBuffer); + gl.deleteTexture(this.renderTarget); + gl.deleteFramebuffer(this.framebuffer); + if (this.depthStencilBuffer) { + gl.deleteRenderbuffer(this.depthStencilBuffer); + } + if (this.depthBuffer) { + gl.deleteRenderbuffer(this.depthBuffer); + } + if (this.stencilBuffer) { + gl.deleteRenderbuffer(this.stencilBuffer); + } + if (this.cardboardUI) { + this.cardboardUI.destroy(); + } + }; + CardboardDistorter.prototype.onResize = function () { + var gl = this.gl; + var self = this; + var glState = [gl.RENDERBUFFER_BINDING, gl.TEXTURE_BINDING_2D, gl.TEXTURE0]; + glPreserveState(gl, glState, function (gl) { + self.realBindFramebuffer.call(gl, gl.FRAMEBUFFER, null); + if (self.scissorTest) { + self.realDisable.call(gl, gl.SCISSOR_TEST); + } + self.realColorMask.call(gl, true, true, true, true); + self.realViewport.call(gl, 0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); + self.realClearColor.call(gl, 0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + self.realBindFramebuffer.call(gl, gl.FRAMEBUFFER, self.framebuffer); + gl.bindTexture(gl.TEXTURE_2D, self.renderTarget); + gl.texImage2D(gl.TEXTURE_2D, 0, self.ctxAttribs.alpha ? gl.RGBA : gl.RGB, self.bufferWidth, self.bufferHeight, 0, self.ctxAttribs.alpha ? gl.RGBA : gl.RGB, gl.UNSIGNED_BYTE, null); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, self.renderTarget, 0); + if (self.ctxAttribs.depth && self.ctxAttribs.stencil) { + gl.bindRenderbuffer(gl.RENDERBUFFER, self.depthStencilBuffer); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_STENCIL, self.bufferWidth, self.bufferHeight); + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, self.depthStencilBuffer); + } else if (self.ctxAttribs.depth) { + gl.bindRenderbuffer(gl.RENDERBUFFER, self.depthBuffer); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, self.bufferWidth, self.bufferHeight); + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, self.depthBuffer); + } else if (self.ctxAttribs.stencil) { + gl.bindRenderbuffer(gl.RENDERBUFFER, self.stencilBuffer); + gl.renderbufferStorage(gl.RENDERBUFFER, gl.STENCIL_INDEX8, self.bufferWidth, self.bufferHeight); + gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.STENCIL_ATTACHMENT, gl.RENDERBUFFER, self.stencilBuffer); + } + if (!gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE) { + console.error('Framebuffer incomplete!'); + } + self.realBindFramebuffer.call(gl, gl.FRAMEBUFFER, self.lastBoundFramebuffer); + if (self.scissorTest) { + self.realEnable.call(gl, gl.SCISSOR_TEST); + } + self.realColorMask.apply(gl, self.colorMask); + self.realViewport.apply(gl, self.viewport); + self.realClearColor.apply(gl, self.clearColor); + }); + if (this.cardboardUI) { + this.cardboardUI.onResize(); + } + }; + CardboardDistorter.prototype.patch = function () { + if (this.isPatched) { + return; + } + var self = this; + var canvas = this.gl.canvas; + var gl = this.gl; + if (!isIOS()) { + canvas.width = getScreenWidth() * this.bufferScale; + canvas.height = getScreenHeight() * this.bufferScale; + Object.defineProperty(canvas, 'width', { + configurable: true, + enumerable: true, + get: function get() { + return self.bufferWidth; + }, + set: function set(value) { + self.bufferWidth = value; + self.realCanvasWidth.set.call(canvas, value); + self.onResize(); + } + }); + Object.defineProperty(canvas, 'height', { + configurable: true, + enumerable: true, + get: function get() { + return self.bufferHeight; + }, + set: function set(value) { + self.bufferHeight = value; + self.realCanvasHeight.set.call(canvas, value); + self.onResize(); + } + }); + } + this.lastBoundFramebuffer = gl.getParameter(gl.FRAMEBUFFER_BINDING); + if (this.lastBoundFramebuffer == null) { + this.lastBoundFramebuffer = this.framebuffer; + this.gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffer); + } + this.gl.bindFramebuffer = function (target, framebuffer) { + self.lastBoundFramebuffer = framebuffer ? framebuffer : self.framebuffer; + self.realBindFramebuffer.call(gl, target, self.lastBoundFramebuffer); + }; + this.cullFace = gl.getParameter(gl.CULL_FACE); + this.depthTest = gl.getParameter(gl.DEPTH_TEST); + this.blend = gl.getParameter(gl.BLEND); + this.scissorTest = gl.getParameter(gl.SCISSOR_TEST); + this.stencilTest = gl.getParameter(gl.STENCIL_TEST); + gl.enable = function (pname) { + switch (pname) { + case gl.CULL_FACE: + self.cullFace = true;break; + case gl.DEPTH_TEST: + self.depthTest = true;break; + case gl.BLEND: + self.blend = true;break; + case gl.SCISSOR_TEST: + self.scissorTest = true;break; + case gl.STENCIL_TEST: + self.stencilTest = true;break; + } + self.realEnable.call(gl, pname); + }; + gl.disable = function (pname) { + switch (pname) { + case gl.CULL_FACE: + self.cullFace = false;break; + case gl.DEPTH_TEST: + self.depthTest = false;break; + case gl.BLEND: + self.blend = false;break; + case gl.SCISSOR_TEST: + self.scissorTest = false;break; + case gl.STENCIL_TEST: + self.stencilTest = false;break; + } + self.realDisable.call(gl, pname); + }; + this.colorMask = gl.getParameter(gl.COLOR_WRITEMASK); + gl.colorMask = function (r, g, b, a) { + self.colorMask[0] = r; + self.colorMask[1] = g; + self.colorMask[2] = b; + self.colorMask[3] = a; + self.realColorMask.call(gl, r, g, b, a); + }; + this.clearColor = gl.getParameter(gl.COLOR_CLEAR_VALUE); + gl.clearColor = function (r, g, b, a) { + self.clearColor[0] = r; + self.clearColor[1] = g; + self.clearColor[2] = b; + self.clearColor[3] = a; + self.realClearColor.call(gl, r, g, b, a); + }; + this.viewport = gl.getParameter(gl.VIEWPORT); + gl.viewport = function (x, y, w, h) { + self.viewport[0] = x; + self.viewport[1] = y; + self.viewport[2] = w; + self.viewport[3] = h; + self.realViewport.call(gl, x, y, w, h); + }; + this.isPatched = true; + safariCssSizeWorkaround(canvas); + }; + CardboardDistorter.prototype.unpatch = function () { + if (!this.isPatched) { + return; + } + var gl = this.gl; + var canvas = this.gl.canvas; + if (!isIOS()) { + Object.defineProperty(canvas, 'width', this.realCanvasWidth); + Object.defineProperty(canvas, 'height', this.realCanvasHeight); + } + canvas.width = this.bufferWidth; + canvas.height = this.bufferHeight; + gl.bindFramebuffer = this.realBindFramebuffer; + gl.enable = this.realEnable; + gl.disable = this.realDisable; + gl.colorMask = this.realColorMask; + gl.clearColor = this.realClearColor; + gl.viewport = this.realViewport; + if (this.lastBoundFramebuffer == this.framebuffer) { + gl.bindFramebuffer(gl.FRAMEBUFFER, null); + } + this.isPatched = false; + setTimeout(function () { + safariCssSizeWorkaround(canvas); + }, 1); + }; + CardboardDistorter.prototype.setTextureBounds = function (leftBounds, rightBounds) { + if (!leftBounds) { + leftBounds = [0, 0, 0.5, 1]; + } + if (!rightBounds) { + rightBounds = [0.5, 0, 0.5, 1]; + } + this.viewportOffsetScale[0] = leftBounds[0]; + this.viewportOffsetScale[1] = leftBounds[1]; + this.viewportOffsetScale[2] = leftBounds[2]; + this.viewportOffsetScale[3] = leftBounds[3]; + this.viewportOffsetScale[4] = rightBounds[0]; + this.viewportOffsetScale[5] = rightBounds[1]; + this.viewportOffsetScale[6] = rightBounds[2]; + this.viewportOffsetScale[7] = rightBounds[3]; + }; + CardboardDistorter.prototype.submitFrame = function () { + var gl = this.gl; + var self = this; + var glState = []; + if (!this.dirtySubmitFrameBindings) { + glState.push(gl.CURRENT_PROGRAM, gl.ARRAY_BUFFER_BINDING, gl.ELEMENT_ARRAY_BUFFER_BINDING, gl.TEXTURE_BINDING_2D, gl.TEXTURE0); + } + glPreserveState(gl, glState, function (gl) { + self.realBindFramebuffer.call(gl, gl.FRAMEBUFFER, null); + if (self.cullFace) { + self.realDisable.call(gl, gl.CULL_FACE); + } + if (self.depthTest) { + self.realDisable.call(gl, gl.DEPTH_TEST); + } + if (self.blend) { + self.realDisable.call(gl, gl.BLEND); + } + if (self.scissorTest) { + self.realDisable.call(gl, gl.SCISSOR_TEST); + } + if (self.stencilTest) { + self.realDisable.call(gl, gl.STENCIL_TEST); + } + self.realColorMask.call(gl, true, true, true, true); + self.realViewport.call(gl, 0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); + if (self.ctxAttribs.alpha || isIOS()) { + self.realClearColor.call(gl, 0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + } + gl.useProgram(self.program); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, self.indexBuffer); + gl.bindBuffer(gl.ARRAY_BUFFER, self.vertexBuffer); + gl.enableVertexAttribArray(self.attribs.position); + gl.enableVertexAttribArray(self.attribs.texCoord); + gl.vertexAttribPointer(self.attribs.position, 2, gl.FLOAT, false, 20, 0); + gl.vertexAttribPointer(self.attribs.texCoord, 3, gl.FLOAT, false, 20, 8); + gl.activeTexture(gl.TEXTURE0); + gl.uniform1i(self.uniforms.diffuse, 0); + gl.bindTexture(gl.TEXTURE_2D, self.renderTarget); + gl.uniform4fv(self.uniforms.viewportOffsetScale, self.viewportOffsetScale); + gl.drawElements(gl.TRIANGLES, self.indexCount, gl.UNSIGNED_SHORT, 0); + if (self.cardboardUI) { + self.cardboardUI.renderNoState(); + } + self.realBindFramebuffer.call(self.gl, gl.FRAMEBUFFER, self.framebuffer); + if (!self.ctxAttribs.preserveDrawingBuffer) { + self.realClearColor.call(gl, 0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + } + if (!self.dirtySubmitFrameBindings) { + self.realBindFramebuffer.call(gl, gl.FRAMEBUFFER, self.lastBoundFramebuffer); + } + if (self.cullFace) { + self.realEnable.call(gl, gl.CULL_FACE); + } + if (self.depthTest) { + self.realEnable.call(gl, gl.DEPTH_TEST); + } + if (self.blend) { + self.realEnable.call(gl, gl.BLEND); + } + if (self.scissorTest) { + self.realEnable.call(gl, gl.SCISSOR_TEST); + } + if (self.stencilTest) { + self.realEnable.call(gl, gl.STENCIL_TEST); + } + self.realColorMask.apply(gl, self.colorMask); + self.realViewport.apply(gl, self.viewport); + if (self.ctxAttribs.alpha || !self.ctxAttribs.preserveDrawingBuffer) { + self.realClearColor.apply(gl, self.clearColor); + } + }); + if (isIOS()) { + var canvas = gl.canvas; + if (canvas.width != self.bufferWidth || canvas.height != self.bufferHeight) { + self.bufferWidth = canvas.width; + self.bufferHeight = canvas.height; + self.onResize(); + } + } + }; + CardboardDistorter.prototype.updateDeviceInfo = function (deviceInfo) { + var gl = this.gl; + var self = this; + var glState = [gl.ARRAY_BUFFER_BINDING, gl.ELEMENT_ARRAY_BUFFER_BINDING]; + glPreserveState(gl, glState, function (gl) { + var vertices = self.computeMeshVertices_(self.meshWidth, self.meshHeight, deviceInfo); + gl.bindBuffer(gl.ARRAY_BUFFER, self.vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW); + if (!self.indexCount) { + var indices = self.computeMeshIndices_(self.meshWidth, self.meshHeight); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, self.indexBuffer); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW); + self.indexCount = indices.length; + } + }); + }; + CardboardDistorter.prototype.computeMeshVertices_ = function (width, height, deviceInfo) { + var vertices = new Float32Array(2 * width * height * 5); + var lensFrustum = deviceInfo.getLeftEyeVisibleTanAngles(); + var noLensFrustum = deviceInfo.getLeftEyeNoLensTanAngles(); + var viewport = deviceInfo.getLeftEyeVisibleScreenRect(noLensFrustum); + var vidx = 0; + for (var e = 0; e < 2; e++) { + for (var j = 0; j < height; j++) { + for (var i = 0; i < width; i++, vidx++) { + var u = i / (width - 1); + var v = j / (height - 1); + var s = u; + var t = v; + var x = lerp(lensFrustum[0], lensFrustum[2], u); + var y = lerp(lensFrustum[3], lensFrustum[1], v); + var d = Math.sqrt(x * x + y * y); + var r = deviceInfo.distortion.distortInverse(d); + var p = x * r / d; + var q = y * r / d; + u = (p - noLensFrustum[0]) / (noLensFrustum[2] - noLensFrustum[0]); + v = (q - noLensFrustum[3]) / (noLensFrustum[1] - noLensFrustum[3]); + u = (viewport.x + u * viewport.width - 0.5) * 2.0; + v = (viewport.y + v * viewport.height - 0.5) * 2.0; + vertices[vidx * 5 + 0] = u; + vertices[vidx * 5 + 1] = v; + vertices[vidx * 5 + 2] = s; + vertices[vidx * 5 + 3] = t; + vertices[vidx * 5 + 4] = e; + } + } + var w = lensFrustum[2] - lensFrustum[0]; + lensFrustum[0] = -(w + lensFrustum[0]); + lensFrustum[2] = w - lensFrustum[2]; + w = noLensFrustum[2] - noLensFrustum[0]; + noLensFrustum[0] = -(w + noLensFrustum[0]); + noLensFrustum[2] = w - noLensFrustum[2]; + viewport.x = 1 - (viewport.x + viewport.width); + } + return vertices; + }; + CardboardDistorter.prototype.computeMeshIndices_ = function (width, height) { + var indices = new Uint16Array(2 * (width - 1) * (height - 1) * 6); + var halfwidth = width / 2; + var halfheight = height / 2; + var vidx = 0; + var iidx = 0; + for (var e = 0; e < 2; e++) { + for (var j = 0; j < height; j++) { + for (var i = 0; i < width; i++, vidx++) { + if (i == 0 || j == 0) continue; + if (i <= halfwidth == j <= halfheight) { + indices[iidx++] = vidx; + indices[iidx++] = vidx - width - 1; + indices[iidx++] = vidx - width; + indices[iidx++] = vidx - width - 1; + indices[iidx++] = vidx; + indices[iidx++] = vidx - 1; + } else { + indices[iidx++] = vidx - 1; + indices[iidx++] = vidx - width; + indices[iidx++] = vidx; + indices[iidx++] = vidx - width; + indices[iidx++] = vidx - 1; + indices[iidx++] = vidx - width - 1; + } + } + } + } + return indices; + }; + CardboardDistorter.prototype.getOwnPropertyDescriptor_ = function (proto, attrName) { + var descriptor = Object.getOwnPropertyDescriptor(proto, attrName); + if (descriptor.get === undefined || descriptor.set === undefined) { + descriptor.configurable = true; + descriptor.enumerable = true; + descriptor.get = function () { + return this.getAttribute(attrName); + }; + descriptor.set = function (val) { + this.setAttribute(attrName, val); + }; + } + return descriptor; + }; + var uiVS = ['attribute vec2 position;', 'uniform mat4 projectionMat;', 'void main() {', ' gl_Position = projectionMat * vec4( position, -1.0, 1.0 );', '}'].join('\n'); + var uiFS = ['precision mediump float;', 'uniform vec4 color;', 'void main() {', ' gl_FragColor = color;', '}'].join('\n'); + var DEG2RAD = Math.PI / 180.0; + var kAnglePerGearSection = 60; + var kOuterRimEndAngle = 12; + var kInnerRimBeginAngle = 20; + var kOuterRadius = 1; + var kMiddleRadius = 0.75; + var kInnerRadius = 0.3125; + var kCenterLineThicknessDp = 4; + var kButtonWidthDp = 28; + var kTouchSlopFactor = 1.5; + function CardboardUI(gl) { + this.gl = gl; + this.attribs = { + position: 0 + }; + this.program = linkProgram(gl, uiVS, uiFS, this.attribs); + this.uniforms = getProgramUniforms(gl, this.program); + this.vertexBuffer = gl.createBuffer(); + this.gearOffset = 0; + this.gearVertexCount = 0; + this.arrowOffset = 0; + this.arrowVertexCount = 0; + this.projMat = new Float32Array(16); + this.listener = null; + this.onResize(); + } + CardboardUI.prototype.destroy = function () { + var gl = this.gl; + if (this.listener) { + gl.canvas.removeEventListener('click', this.listener, false); + } + gl.deleteProgram(this.program); + gl.deleteBuffer(this.vertexBuffer); + }; + CardboardUI.prototype.listen = function (optionsCallback, backCallback) { + var canvas = this.gl.canvas; + this.listener = function (event) { + var midline = canvas.clientWidth / 2; + var buttonSize = kButtonWidthDp * kTouchSlopFactor; + if (event.clientX > midline - buttonSize && event.clientX < midline + buttonSize && event.clientY > canvas.clientHeight - buttonSize) { + optionsCallback(event); + } + else if (event.clientX < buttonSize && event.clientY < buttonSize) { + backCallback(event); + } + }; + canvas.addEventListener('click', this.listener, false); + }; + CardboardUI.prototype.onResize = function () { + var gl = this.gl; + var self = this; + var glState = [gl.ARRAY_BUFFER_BINDING]; + glPreserveState(gl, glState, function (gl) { + var vertices = []; + var midline = gl.drawingBufferWidth / 2; + var physicalPixels = Math.max(screen.width, screen.height) * window.devicePixelRatio; + var scalingRatio = gl.drawingBufferWidth / physicalPixels; + var dps = scalingRatio * window.devicePixelRatio; + var lineWidth = kCenterLineThicknessDp * dps / 2; + var buttonSize = kButtonWidthDp * kTouchSlopFactor * dps; + var buttonScale = kButtonWidthDp * dps / 2; + var buttonBorder = (kButtonWidthDp * kTouchSlopFactor - kButtonWidthDp) * dps; + vertices.push(midline - lineWidth, buttonSize); + vertices.push(midline - lineWidth, gl.drawingBufferHeight); + vertices.push(midline + lineWidth, buttonSize); + vertices.push(midline + lineWidth, gl.drawingBufferHeight); + self.gearOffset = vertices.length / 2; + function addGearSegment(theta, r) { + var angle = (90 - theta) * DEG2RAD; + var x = Math.cos(angle); + var y = Math.sin(angle); + vertices.push(kInnerRadius * x * buttonScale + midline, kInnerRadius * y * buttonScale + buttonScale); + vertices.push(r * x * buttonScale + midline, r * y * buttonScale + buttonScale); + } + for (var i = 0; i <= 6; i++) { + var segmentTheta = i * kAnglePerGearSection; + addGearSegment(segmentTheta, kOuterRadius); + addGearSegment(segmentTheta + kOuterRimEndAngle, kOuterRadius); + addGearSegment(segmentTheta + kInnerRimBeginAngle, kMiddleRadius); + addGearSegment(segmentTheta + (kAnglePerGearSection - kInnerRimBeginAngle), kMiddleRadius); + addGearSegment(segmentTheta + (kAnglePerGearSection - kOuterRimEndAngle), kOuterRadius); + } + self.gearVertexCount = vertices.length / 2 - self.gearOffset; + self.arrowOffset = vertices.length / 2; + function addArrowVertex(x, y) { + vertices.push(buttonBorder + x, gl.drawingBufferHeight - buttonBorder - y); + } + var angledLineWidth = lineWidth / Math.sin(45 * DEG2RAD); + addArrowVertex(0, buttonScale); + addArrowVertex(buttonScale, 0); + addArrowVertex(buttonScale + angledLineWidth, angledLineWidth); + addArrowVertex(angledLineWidth, buttonScale + angledLineWidth); + addArrowVertex(angledLineWidth, buttonScale - angledLineWidth); + addArrowVertex(0, buttonScale); + addArrowVertex(buttonScale, buttonScale * 2); + addArrowVertex(buttonScale + angledLineWidth, buttonScale * 2 - angledLineWidth); + addArrowVertex(angledLineWidth, buttonScale - angledLineWidth); + addArrowVertex(0, buttonScale); + addArrowVertex(angledLineWidth, buttonScale - lineWidth); + addArrowVertex(kButtonWidthDp * dps, buttonScale - lineWidth); + addArrowVertex(angledLineWidth, buttonScale + lineWidth); + addArrowVertex(kButtonWidthDp * dps, buttonScale + lineWidth); + self.arrowVertexCount = vertices.length / 2 - self.arrowOffset; + gl.bindBuffer(gl.ARRAY_BUFFER, self.vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); + }); + }; + CardboardUI.prototype.render = function () { + var gl = this.gl; + var self = this; + var glState = [gl.CULL_FACE, gl.DEPTH_TEST, gl.BLEND, gl.SCISSOR_TEST, gl.STENCIL_TEST, gl.COLOR_WRITEMASK, gl.VIEWPORT, gl.CURRENT_PROGRAM, gl.ARRAY_BUFFER_BINDING]; + glPreserveState(gl, glState, function (gl) { + gl.disable(gl.CULL_FACE); + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.BLEND); + gl.disable(gl.SCISSOR_TEST); + gl.disable(gl.STENCIL_TEST); + gl.colorMask(true, true, true, true); + gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight); + self.renderNoState(); + }); + }; + CardboardUI.prototype.renderNoState = function () { + var gl = this.gl; + gl.useProgram(this.program); + gl.bindBuffer(gl.ARRAY_BUFFER, this.vertexBuffer); + gl.enableVertexAttribArray(this.attribs.position); + gl.vertexAttribPointer(this.attribs.position, 2, gl.FLOAT, false, 8, 0); + gl.uniform4f(this.uniforms.color, 1.0, 1.0, 1.0, 1.0); + orthoMatrix(this.projMat, 0, gl.drawingBufferWidth, 0, gl.drawingBufferHeight, 0.1, 1024.0); + gl.uniformMatrix4fv(this.uniforms.projectionMat, false, this.projMat); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + gl.drawArrays(gl.TRIANGLE_STRIP, this.gearOffset, this.gearVertexCount); + gl.drawArrays(gl.TRIANGLE_STRIP, this.arrowOffset, this.arrowVertexCount); + }; + function Distortion(coefficients) { + this.coefficients = coefficients; + } + Distortion.prototype.distortInverse = function (radius) { + var r0 = 0; + var r1 = 1; + var dr0 = radius - this.distort(r0); + while (Math.abs(r1 - r0) > 0.0001 ) { + var dr1 = radius - this.distort(r1); + var r2 = r1 - dr1 * ((r1 - r0) / (dr1 - dr0)); + r0 = r1; + r1 = r2; + dr0 = dr1; + } + return r1; + }; + Distortion.prototype.distort = function (radius) { + var r2 = radius * radius; + var ret = 0; + for (var i = 0; i < this.coefficients.length; i++) { + ret = r2 * (ret + this.coefficients[i]); + } + return (ret + 1) * radius; + }; + var degToRad = Math.PI / 180; + var radToDeg = 180 / Math.PI; + var Vector3 = function Vector3(x, y, z) { + this.x = x || 0; + this.y = y || 0; + this.z = z || 0; + }; + Vector3.prototype = { + constructor: Vector3, + set: function set(x, y, z) { + this.x = x; + this.y = y; + this.z = z; + return this; + }, + copy: function copy(v) { + this.x = v.x; + this.y = v.y; + this.z = v.z; + return this; + }, + length: function length() { + return Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z); + }, + normalize: function normalize() { + var scalar = this.length(); + if (scalar !== 0) { + var invScalar = 1 / scalar; + this.multiplyScalar(invScalar); + } else { + this.x = 0; + this.y = 0; + this.z = 0; + } + return this; + }, + multiplyScalar: function multiplyScalar(scalar) { + this.x *= scalar; + this.y *= scalar; + this.z *= scalar; + }, + applyQuaternion: function applyQuaternion(q) { + var x = this.x; + var y = this.y; + var z = this.z; + var qx = q.x; + var qy = q.y; + var qz = q.z; + var qw = q.w; + var ix = qw * x + qy * z - qz * y; + var iy = qw * y + qz * x - qx * z; + var iz = qw * z + qx * y - qy * x; + var iw = -qx * x - qy * y - qz * z; + this.x = ix * qw + iw * -qx + iy * -qz - iz * -qy; + this.y = iy * qw + iw * -qy + iz * -qx - ix * -qz; + this.z = iz * qw + iw * -qz + ix * -qy - iy * -qx; + return this; + }, + dot: function dot(v) { + return this.x * v.x + this.y * v.y + this.z * v.z; + }, + crossVectors: function crossVectors(a, b) { + var ax = a.x, + ay = a.y, + az = a.z; + var bx = b.x, + by = b.y, + bz = b.z; + this.x = ay * bz - az * by; + this.y = az * bx - ax * bz; + this.z = ax * by - ay * bx; + return this; + } + }; + var Quaternion = function Quaternion(x, y, z, w) { + this.x = x || 0; + this.y = y || 0; + this.z = z || 0; + this.w = w !== undefined ? w : 1; + }; + Quaternion.prototype = { + constructor: Quaternion, + set: function set(x, y, z, w) { + this.x = x; + this.y = y; + this.z = z; + this.w = w; + return this; + }, + copy: function copy(quaternion) { + this.x = quaternion.x; + this.y = quaternion.y; + this.z = quaternion.z; + this.w = quaternion.w; + return this; + }, + setFromEulerXYZ: function setFromEulerXYZ(x, y, z) { + var c1 = Math.cos(x / 2); + var c2 = Math.cos(y / 2); + var c3 = Math.cos(z / 2); + var s1 = Math.sin(x / 2); + var s2 = Math.sin(y / 2); + var s3 = Math.sin(z / 2); + this.x = s1 * c2 * c3 + c1 * s2 * s3; + this.y = c1 * s2 * c3 - s1 * c2 * s3; + this.z = c1 * c2 * s3 + s1 * s2 * c3; + this.w = c1 * c2 * c3 - s1 * s2 * s3; + return this; + }, + setFromEulerYXZ: function setFromEulerYXZ(x, y, z) { + var c1 = Math.cos(x / 2); + var c2 = Math.cos(y / 2); + var c3 = Math.cos(z / 2); + var s1 = Math.sin(x / 2); + var s2 = Math.sin(y / 2); + var s3 = Math.sin(z / 2); + this.x = s1 * c2 * c3 + c1 * s2 * s3; + this.y = c1 * s2 * c3 - s1 * c2 * s3; + this.z = c1 * c2 * s3 - s1 * s2 * c3; + this.w = c1 * c2 * c3 + s1 * s2 * s3; + return this; + }, + setFromAxisAngle: function setFromAxisAngle(axis, angle) { + var halfAngle = angle / 2, + s = Math.sin(halfAngle); + this.x = axis.x * s; + this.y = axis.y * s; + this.z = axis.z * s; + this.w = Math.cos(halfAngle); + return this; + }, + multiply: function multiply(q) { + return this.multiplyQuaternions(this, q); + }, + multiplyQuaternions: function multiplyQuaternions(a, b) { + var qax = a.x, + qay = a.y, + qaz = a.z, + qaw = a.w; + var qbx = b.x, + qby = b.y, + qbz = b.z, + qbw = b.w; + this.x = qax * qbw + qaw * qbx + qay * qbz - qaz * qby; + this.y = qay * qbw + qaw * qby + qaz * qbx - qax * qbz; + this.z = qaz * qbw + qaw * qbz + qax * qby - qay * qbx; + this.w = qaw * qbw - qax * qbx - qay * qby - qaz * qbz; + return this; + }, + inverse: function inverse() { + this.x *= -1; + this.y *= -1; + this.z *= -1; + this.normalize(); + return this; + }, + normalize: function normalize() { + var l = Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w); + if (l === 0) { + this.x = 0; + this.y = 0; + this.z = 0; + this.w = 1; + } else { + l = 1 / l; + this.x = this.x * l; + this.y = this.y * l; + this.z = this.z * l; + this.w = this.w * l; + } + return this; + }, + slerp: function slerp(qb, t) { + if (t === 0) return this; + if (t === 1) return this.copy(qb); + var x = this.x, + y = this.y, + z = this.z, + w = this.w; + var cosHalfTheta = w * qb.w + x * qb.x + y * qb.y + z * qb.z; + if (cosHalfTheta < 0) { + this.w = -qb.w; + this.x = -qb.x; + this.y = -qb.y; + this.z = -qb.z; + cosHalfTheta = -cosHalfTheta; + } else { + this.copy(qb); + } + if (cosHalfTheta >= 1.0) { + this.w = w; + this.x = x; + this.y = y; + this.z = z; + return this; + } + var halfTheta = Math.acos(cosHalfTheta); + var sinHalfTheta = Math.sqrt(1.0 - cosHalfTheta * cosHalfTheta); + if (Math.abs(sinHalfTheta) < 0.001) { + this.w = 0.5 * (w + this.w); + this.x = 0.5 * (x + this.x); + this.y = 0.5 * (y + this.y); + this.z = 0.5 * (z + this.z); + return this; + } + var ratioA = Math.sin((1 - t) * halfTheta) / sinHalfTheta, + ratioB = Math.sin(t * halfTheta) / sinHalfTheta; + this.w = w * ratioA + this.w * ratioB; + this.x = x * ratioA + this.x * ratioB; + this.y = y * ratioA + this.y * ratioB; + this.z = z * ratioA + this.z * ratioB; + return this; + }, + setFromUnitVectors: function () { + var v1, r; + var EPS = 0.000001; + return function (vFrom, vTo) { + if (v1 === undefined) v1 = new Vector3(); + r = vFrom.dot(vTo) + 1; + if (r < EPS) { + r = 0; + if (Math.abs(vFrom.x) > Math.abs(vFrom.z)) { + v1.set(-vFrom.y, vFrom.x, 0); + } else { + v1.set(0, -vFrom.z, vFrom.y); + } + } else { + v1.crossVectors(vFrom, vTo); + } + this.x = v1.x; + this.y = v1.y; + this.z = v1.z; + this.w = r; + this.normalize(); + return this; + }; + }() + }; + function Device(params) { + this.width = params.width || getScreenWidth(); + this.height = params.height || getScreenHeight(); + this.widthMeters = params.widthMeters; + this.heightMeters = params.heightMeters; + this.bevelMeters = params.bevelMeters; + } + var DEFAULT_ANDROID = new Device({ + widthMeters: 0.110, + heightMeters: 0.062, + bevelMeters: 0.004 + }); + var DEFAULT_IOS = new Device({ + widthMeters: 0.1038, + heightMeters: 0.0584, + bevelMeters: 0.004 + }); + var Viewers = { + CardboardV1: new CardboardViewer({ + id: 'CardboardV1', + label: 'Cardboard I/O 2014', + fov: 40, + interLensDistance: 0.060, + baselineLensDistance: 0.035, + screenLensDistance: 0.042, + distortionCoefficients: [0.441, 0.156], + inverseCoefficients: [-0.4410035, 0.42756155, -0.4804439, 0.5460139, -0.58821183, 0.5733938, -0.48303202, 0.33299083, -0.17573841, 0.0651772, -0.01488963, 0.001559834] + }), + CardboardV2: new CardboardViewer({ + id: 'CardboardV2', + label: 'Cardboard I/O 2015', + fov: 60, + interLensDistance: 0.064, + baselineLensDistance: 0.035, + screenLensDistance: 0.039, + distortionCoefficients: [0.34, 0.55], + inverseCoefficients: [-0.33836704, -0.18162185, 0.862655, -1.2462051, 1.0560602, -0.58208317, 0.21609078, -0.05444823, 0.009177956, -9.904169E-4, 6.183535E-5, -1.6981803E-6] + }) + }; + function DeviceInfo(deviceParams, additionalViewers) { + this.viewer = Viewers.CardboardV2; + this.updateDeviceParams(deviceParams); + this.distortion = new Distortion(this.viewer.distortionCoefficients); + for (var i = 0; i < additionalViewers.length; i++) { + var viewer = additionalViewers[i]; + Viewers[viewer.id] = new CardboardViewer(viewer); + } + } + DeviceInfo.prototype.updateDeviceParams = function (deviceParams) { + this.device = this.determineDevice_(deviceParams) || this.device; + }; + DeviceInfo.prototype.getDevice = function () { + return this.device; + }; + DeviceInfo.prototype.setViewer = function (viewer) { + this.viewer = viewer; + this.distortion = new Distortion(this.viewer.distortionCoefficients); + }; + DeviceInfo.prototype.determineDevice_ = function (deviceParams) { + if (!deviceParams) { + if (isIOS()) { + console.warn('Using fallback iOS device measurements.'); + return DEFAULT_IOS; + } else { + console.warn('Using fallback Android device measurements.'); + return DEFAULT_ANDROID; + } + } + var METERS_PER_INCH = 0.0254; + var metersPerPixelX = METERS_PER_INCH / deviceParams.xdpi; + var metersPerPixelY = METERS_PER_INCH / deviceParams.ydpi; + var width = getScreenWidth(); + var height = getScreenHeight(); + return new Device({ + widthMeters: metersPerPixelX * width, + heightMeters: metersPerPixelY * height, + bevelMeters: deviceParams.bevelMm * 0.001 + }); + }; + DeviceInfo.prototype.getDistortedFieldOfViewLeftEye = function () { + var viewer = this.viewer; + var device = this.device; + var distortion = this.distortion; + var eyeToScreenDistance = viewer.screenLensDistance; + var outerDist = (device.widthMeters - viewer.interLensDistance) / 2; + var innerDist = viewer.interLensDistance / 2; + var bottomDist = viewer.baselineLensDistance - device.bevelMeters; + var topDist = device.heightMeters - bottomDist; + var outerAngle = radToDeg * Math.atan(distortion.distort(outerDist / eyeToScreenDistance)); + var innerAngle = radToDeg * Math.atan(distortion.distort(innerDist / eyeToScreenDistance)); + var bottomAngle = radToDeg * Math.atan(distortion.distort(bottomDist / eyeToScreenDistance)); + var topAngle = radToDeg * Math.atan(distortion.distort(topDist / eyeToScreenDistance)); + return { + leftDegrees: Math.min(outerAngle, viewer.fov), + rightDegrees: Math.min(innerAngle, viewer.fov), + downDegrees: Math.min(bottomAngle, viewer.fov), + upDegrees: Math.min(topAngle, viewer.fov) + }; + }; + DeviceInfo.prototype.getLeftEyeVisibleTanAngles = function () { + var viewer = this.viewer; + var device = this.device; + var distortion = this.distortion; + var fovLeft = Math.tan(-degToRad * viewer.fov); + var fovTop = Math.tan(degToRad * viewer.fov); + var fovRight = Math.tan(degToRad * viewer.fov); + var fovBottom = Math.tan(-degToRad * viewer.fov); + var halfWidth = device.widthMeters / 4; + var halfHeight = device.heightMeters / 2; + var verticalLensOffset = viewer.baselineLensDistance - device.bevelMeters - halfHeight; + var centerX = viewer.interLensDistance / 2 - halfWidth; + var centerY = -verticalLensOffset; + var centerZ = viewer.screenLensDistance; + var screenLeft = distortion.distort((centerX - halfWidth) / centerZ); + var screenTop = distortion.distort((centerY + halfHeight) / centerZ); + var screenRight = distortion.distort((centerX + halfWidth) / centerZ); + var screenBottom = distortion.distort((centerY - halfHeight) / centerZ); + var result = new Float32Array(4); + result[0] = Math.max(fovLeft, screenLeft); + result[1] = Math.min(fovTop, screenTop); + result[2] = Math.min(fovRight, screenRight); + result[3] = Math.max(fovBottom, screenBottom); + return result; + }; + DeviceInfo.prototype.getLeftEyeNoLensTanAngles = function () { + var viewer = this.viewer; + var device = this.device; + var distortion = this.distortion; + var result = new Float32Array(4); + var fovLeft = distortion.distortInverse(Math.tan(-degToRad * viewer.fov)); + var fovTop = distortion.distortInverse(Math.tan(degToRad * viewer.fov)); + var fovRight = distortion.distortInverse(Math.tan(degToRad * viewer.fov)); + var fovBottom = distortion.distortInverse(Math.tan(-degToRad * viewer.fov)); + var halfWidth = device.widthMeters / 4; + var halfHeight = device.heightMeters / 2; + var verticalLensOffset = viewer.baselineLensDistance - device.bevelMeters - halfHeight; + var centerX = viewer.interLensDistance / 2 - halfWidth; + var centerY = -verticalLensOffset; + var centerZ = viewer.screenLensDistance; + var screenLeft = (centerX - halfWidth) / centerZ; + var screenTop = (centerY + halfHeight) / centerZ; + var screenRight = (centerX + halfWidth) / centerZ; + var screenBottom = (centerY - halfHeight) / centerZ; + result[0] = Math.max(fovLeft, screenLeft); + result[1] = Math.min(fovTop, screenTop); + result[2] = Math.min(fovRight, screenRight); + result[3] = Math.max(fovBottom, screenBottom); + return result; + }; + DeviceInfo.prototype.getLeftEyeVisibleScreenRect = function (undistortedFrustum) { + var viewer = this.viewer; + var device = this.device; + var dist = viewer.screenLensDistance; + var eyeX = (device.widthMeters - viewer.interLensDistance) / 2; + var eyeY = viewer.baselineLensDistance - device.bevelMeters; + var left = (undistortedFrustum[0] * dist + eyeX) / device.widthMeters; + var top = (undistortedFrustum[1] * dist + eyeY) / device.heightMeters; + var right = (undistortedFrustum[2] * dist + eyeX) / device.widthMeters; + var bottom = (undistortedFrustum[3] * dist + eyeY) / device.heightMeters; + return { + x: left, + y: bottom, + width: right - left, + height: top - bottom + }; + }; + DeviceInfo.prototype.getFieldOfViewLeftEye = function (opt_isUndistorted) { + return opt_isUndistorted ? this.getUndistortedFieldOfViewLeftEye() : this.getDistortedFieldOfViewLeftEye(); + }; + DeviceInfo.prototype.getFieldOfViewRightEye = function (opt_isUndistorted) { + var fov = this.getFieldOfViewLeftEye(opt_isUndistorted); + return { + leftDegrees: fov.rightDegrees, + rightDegrees: fov.leftDegrees, + upDegrees: fov.upDegrees, + downDegrees: fov.downDegrees + }; + }; + DeviceInfo.prototype.getUndistortedFieldOfViewLeftEye = function () { + var p = this.getUndistortedParams_(); + return { + leftDegrees: radToDeg * Math.atan(p.outerDist), + rightDegrees: radToDeg * Math.atan(p.innerDist), + downDegrees: radToDeg * Math.atan(p.bottomDist), + upDegrees: radToDeg * Math.atan(p.topDist) + }; + }; + DeviceInfo.prototype.getUndistortedViewportLeftEye = function () { + var p = this.getUndistortedParams_(); + var viewer = this.viewer; + var device = this.device; + var eyeToScreenDistance = viewer.screenLensDistance; + var screenWidth = device.widthMeters / eyeToScreenDistance; + var screenHeight = device.heightMeters / eyeToScreenDistance; + var xPxPerTanAngle = device.width / screenWidth; + var yPxPerTanAngle = device.height / screenHeight; + var x = Math.round((p.eyePosX - p.outerDist) * xPxPerTanAngle); + var y = Math.round((p.eyePosY - p.bottomDist) * yPxPerTanAngle); + return { + x: x, + y: y, + width: Math.round((p.eyePosX + p.innerDist) * xPxPerTanAngle) - x, + height: Math.round((p.eyePosY + p.topDist) * yPxPerTanAngle) - y + }; + }; + DeviceInfo.prototype.getUndistortedParams_ = function () { + var viewer = this.viewer; + var device = this.device; + var distortion = this.distortion; + var eyeToScreenDistance = viewer.screenLensDistance; + var halfLensDistance = viewer.interLensDistance / 2 / eyeToScreenDistance; + var screenWidth = device.widthMeters / eyeToScreenDistance; + var screenHeight = device.heightMeters / eyeToScreenDistance; + var eyePosX = screenWidth / 2 - halfLensDistance; + var eyePosY = (viewer.baselineLensDistance - device.bevelMeters) / eyeToScreenDistance; + var maxFov = viewer.fov; + var viewerMax = distortion.distortInverse(Math.tan(degToRad * maxFov)); + var outerDist = Math.min(eyePosX, viewerMax); + var innerDist = Math.min(halfLensDistance, viewerMax); + var bottomDist = Math.min(eyePosY, viewerMax); + var topDist = Math.min(screenHeight - eyePosY, viewerMax); + return { + outerDist: outerDist, + innerDist: innerDist, + topDist: topDist, + bottomDist: bottomDist, + eyePosX: eyePosX, + eyePosY: eyePosY + }; + }; + function CardboardViewer(params) { + this.id = params.id; + this.label = params.label; + this.fov = params.fov; + this.interLensDistance = params.interLensDistance; + this.baselineLensDistance = params.baselineLensDistance; + this.screenLensDistance = params.screenLensDistance; + this.distortionCoefficients = params.distortionCoefficients; + this.inverseCoefficients = params.inverseCoefficients; + } + DeviceInfo.Viewers = Viewers; + var format = 1; + var last_updated = "2018-02-20T22:55:10Z"; + var devices = [{"type":"android","rules":[{"mdmh":"asus/*/Nexus 7/*"},{"ua":"Nexus 7"}],"dpi":[320.8,323],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"asus/*/ASUS_Z00AD/*"},{"ua":"ASUS_Z00AD"}],"dpi":[403,404.6],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"Google/*/Pixel XL/*"},{"ua":"Pixel XL"}],"dpi":[537.9,533],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"Google/*/Pixel/*"},{"ua":"Pixel"}],"dpi":[432.6,436.7],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"HTC/*/HTC6435LVW/*"},{"ua":"HTC6435LVW"}],"dpi":[449.7,443.3],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"HTC/*/HTC One XL/*"},{"ua":"HTC One XL"}],"dpi":[315.3,314.6],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"htc/*/Nexus 9/*"},{"ua":"Nexus 9"}],"dpi":289,"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"HTC/*/HTC One M9/*"},{"ua":"HTC One M9"}],"dpi":[442.5,443.3],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"HTC/*/HTC One_M8/*"},{"ua":"HTC One_M8"}],"dpi":[449.7,447.4],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"HTC/*/HTC One/*"},{"ua":"HTC One"}],"dpi":472.8,"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"Huawei/*/Nexus 6P/*"},{"ua":"Nexus 6P"}],"dpi":[515.1,518],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"LENOVO/*/Lenovo PB2-690Y/*"},{"ua":"Lenovo PB2-690Y"}],"dpi":[457.2,454.713],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"LGE/*/Nexus 5X/*"},{"ua":"Nexus 5X"}],"dpi":[422,419.9],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"LGE/*/LGMS345/*"},{"ua":"LGMS345"}],"dpi":[221.7,219.1],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"LGE/*/LG-D800/*"},{"ua":"LG-D800"}],"dpi":[422,424.1],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"LGE/*/LG-D850/*"},{"ua":"LG-D850"}],"dpi":[537.9,541.9],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"LGE/*/VS985 4G/*"},{"ua":"VS985 4G"}],"dpi":[537.9,535.6],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"LGE/*/Nexus 5/*"},{"ua":"Nexus 5 B"}],"dpi":[442.4,444.8],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"LGE/*/Nexus 4/*"},{"ua":"Nexus 4"}],"dpi":[319.8,318.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"LGE/*/LG-P769/*"},{"ua":"LG-P769"}],"dpi":[240.6,247.5],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"LGE/*/LGMS323/*"},{"ua":"LGMS323"}],"dpi":[206.6,204.6],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"LGE/*/LGLS996/*"},{"ua":"LGLS996"}],"dpi":[403.4,401.5],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"Micromax/*/4560MMX/*"},{"ua":"4560MMX"}],"dpi":[240,219.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"Micromax/*/A250/*"},{"ua":"Micromax A250"}],"dpi":[480,446.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"Micromax/*/Micromax AQ4501/*"},{"ua":"Micromax AQ4501"}],"dpi":240,"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"motorola/*/G5/*"},{"ua":"Moto G (5) Plus"}],"dpi":[403.4,403],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"motorola/*/DROID RAZR/*"},{"ua":"DROID RAZR"}],"dpi":[368.1,256.7],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"motorola/*/XT830C/*"},{"ua":"XT830C"}],"dpi":[254,255.9],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"motorola/*/XT1021/*"},{"ua":"XT1021"}],"dpi":[254,256.7],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"motorola/*/XT1023/*"},{"ua":"XT1023"}],"dpi":[254,256.7],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"motorola/*/XT1028/*"},{"ua":"XT1028"}],"dpi":[326.6,327.6],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"motorola/*/XT1034/*"},{"ua":"XT1034"}],"dpi":[326.6,328.4],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"motorola/*/XT1053/*"},{"ua":"XT1053"}],"dpi":[315.3,316.1],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"motorola/*/XT1562/*"},{"ua":"XT1562"}],"dpi":[403.4,402.7],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"motorola/*/Nexus 6/*"},{"ua":"Nexus 6 B"}],"dpi":[494.3,489.7],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"motorola/*/XT1063/*"},{"ua":"XT1063"}],"dpi":[295,296.6],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"motorola/*/XT1064/*"},{"ua":"XT1064"}],"dpi":[295,295.6],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"motorola/*/XT1092/*"},{"ua":"XT1092"}],"dpi":[422,424.1],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"motorola/*/XT1095/*"},{"ua":"XT1095"}],"dpi":[422,423.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"motorola/*/G4/*"},{"ua":"Moto G (4)"}],"dpi":401,"bw":4,"ac":1000},{"type":"android","rules":[{"mdmh":"OnePlus/*/A0001/*"},{"ua":"A0001"}],"dpi":[403.4,401],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"OnePlus/*/ONE E1005/*"},{"ua":"ONE E1005"}],"dpi":[442.4,441.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"OnePlus/*/ONE A2005/*"},{"ua":"ONE A2005"}],"dpi":[391.9,405.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"OnePlus/*/ONEPLUS A5000/*"},{"ua":"ONEPLUS A5000 "}],"dpi":[403.411,399.737],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"OnePlus/*/ONE A5010/*"},{"ua":"ONEPLUS A5010"}],"dpi":[403,400],"bw":2,"ac":1000},{"type":"android","rules":[{"mdmh":"OPPO/*/X909/*"},{"ua":"X909"}],"dpi":[442.4,444.1],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/GT-I9082/*"},{"ua":"GT-I9082"}],"dpi":[184.7,185.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G360P/*"},{"ua":"SM-G360P"}],"dpi":[196.7,205.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/Nexus S/*"},{"ua":"Nexus S"}],"dpi":[234.5,229.8],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/GT-I9300/*"},{"ua":"GT-I9300"}],"dpi":[304.8,303.9],"bw":5,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SM-T230NU/*"},{"ua":"SM-T230NU"}],"dpi":216,"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SGH-T399/*"},{"ua":"SGH-T399"}],"dpi":[217.7,231.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SGH-M919/*"},{"ua":"SGH-M919"}],"dpi":[440.8,437.7],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-N9005/*"},{"ua":"SM-N9005"}],"dpi":[386.4,387],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SAMSUNG-SM-N900A/*"},{"ua":"SAMSUNG-SM-N900A"}],"dpi":[386.4,387.7],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/GT-I9500/*"},{"ua":"GT-I9500"}],"dpi":[442.5,443.3],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/GT-I9505/*"},{"ua":"GT-I9505"}],"dpi":439.4,"bw":4,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G900F/*"},{"ua":"SM-G900F"}],"dpi":[415.6,431.6],"bw":5,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G900M/*"},{"ua":"SM-G900M"}],"dpi":[415.6,431.6],"bw":5,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G800F/*"},{"ua":"SM-G800F"}],"dpi":326.8,"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G906S/*"},{"ua":"SM-G906S"}],"dpi":[562.7,572.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/GT-I9300/*"},{"ua":"GT-I9300"}],"dpi":[306.7,304.8],"bw":5,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-T535/*"},{"ua":"SM-T535"}],"dpi":[142.6,136.4],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SM-N920C/*"},{"ua":"SM-N920C"}],"dpi":[515.1,518.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-N920P/*"},{"ua":"SM-N920P"}],"dpi":[386.3655,390.144],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-N920W8/*"},{"ua":"SM-N920W8"}],"dpi":[515.1,518.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/GT-I9300I/*"},{"ua":"GT-I9300I"}],"dpi":[304.8,305.8],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/GT-I9195/*"},{"ua":"GT-I9195"}],"dpi":[249.4,256.7],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SPH-L520/*"},{"ua":"SPH-L520"}],"dpi":[249.4,255.9],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SAMSUNG-SGH-I717/*"},{"ua":"SAMSUNG-SGH-I717"}],"dpi":285.8,"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SPH-D710/*"},{"ua":"SPH-D710"}],"dpi":[217.7,204.2],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/GT-N7100/*"},{"ua":"GT-N7100"}],"dpi":265.1,"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SCH-I605/*"},{"ua":"SCH-I605"}],"dpi":265.1,"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/Galaxy Nexus/*"},{"ua":"Galaxy Nexus"}],"dpi":[315.3,314.2],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-N910H/*"},{"ua":"SM-N910H"}],"dpi":[515.1,518],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-N910C/*"},{"ua":"SM-N910C"}],"dpi":[515.2,520.2],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G130M/*"},{"ua":"SM-G130M"}],"dpi":[165.9,164.8],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G928I/*"},{"ua":"SM-G928I"}],"dpi":[515.1,518.4],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G920F/*"},{"ua":"SM-G920F"}],"dpi":580.6,"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G920P/*"},{"ua":"SM-G920P"}],"dpi":[522.5,577],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G925F/*"},{"ua":"SM-G925F"}],"dpi":580.6,"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G925V/*"},{"ua":"SM-G925V"}],"dpi":[522.5,576.6],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G930F/*"},{"ua":"SM-G930F"}],"dpi":576.6,"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G935F/*"},{"ua":"SM-G935F"}],"dpi":533,"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G950F/*"},{"ua":"SM-G950F"}],"dpi":[562.707,565.293],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"samsung/*/SM-G955U/*"},{"ua":"SM-G955U"}],"dpi":[522.514,525.762],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"Sony/*/C6903/*"},{"ua":"C6903"}],"dpi":[442.5,443.3],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"Sony/*/D6653/*"},{"ua":"D6653"}],"dpi":[428.6,427.6],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"Sony/*/E6653/*"},{"ua":"E6653"}],"dpi":[428.6,425.7],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"Sony/*/E6853/*"},{"ua":"E6853"}],"dpi":[403.4,401.9],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"Sony/*/SGP321/*"},{"ua":"SGP321"}],"dpi":[224.7,224.1],"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"TCT/*/ALCATEL ONE TOUCH Fierce/*"},{"ua":"ALCATEL ONE TOUCH Fierce"}],"dpi":[240,247.5],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"THL/*/thl 5000/*"},{"ua":"thl 5000"}],"dpi":[480,443.3],"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"Fly/*/IQ4412/*"},{"ua":"IQ4412"}],"dpi":307.9,"bw":3,"ac":1000},{"type":"android","rules":[{"mdmh":"ZTE/*/ZTE Blade L2/*"},{"ua":"ZTE Blade L2"}],"dpi":240,"bw":3,"ac":500},{"type":"android","rules":[{"mdmh":"BENEVE/*/VR518/*"},{"ua":"VR518"}],"dpi":480,"bw":3,"ac":500},{"type":"ios","rules":[{"res":[640,960]}],"dpi":[325.1,328.4],"bw":4,"ac":1000},{"type":"ios","rules":[{"res":[640,1136]}],"dpi":[317.1,320.2],"bw":3,"ac":1000},{"type":"ios","rules":[{"res":[750,1334]}],"dpi":326.4,"bw":4,"ac":1000},{"type":"ios","rules":[{"res":[1242,2208]}],"dpi":[453.6,458.4],"bw":4,"ac":1000},{"type":"ios","rules":[{"res":[1125,2001]}],"dpi":[410.9,415.4],"bw":4,"ac":1000},{"type":"ios","rules":[{"res":[1125,2436]}],"dpi":458,"bw":4,"ac":1000}]; + var DPDB_CACHE = { + format: format, + last_updated: last_updated, + devices: devices + }; + function Dpdb(url, onDeviceParamsUpdated) { + this.dpdb = DPDB_CACHE; + this.recalculateDeviceParams_(); + if (url) { + this.onDeviceParamsUpdated = onDeviceParamsUpdated; + var xhr = new XMLHttpRequest(); + var obj = this; + xhr.open('GET', url, true); + xhr.addEventListener('load', function () { + obj.loading = false; + if (xhr.status >= 200 && xhr.status <= 299) { + obj.dpdb = JSON.parse(xhr.response); + obj.recalculateDeviceParams_(); + } else { + console.error('Error loading online DPDB!'); + } + }); + xhr.send(); + } + } + Dpdb.prototype.getDeviceParams = function () { + return this.deviceParams; + }; + Dpdb.prototype.recalculateDeviceParams_ = function () { + var newDeviceParams = this.calcDeviceParams_(); + if (newDeviceParams) { + this.deviceParams = newDeviceParams; + if (this.onDeviceParamsUpdated) { + this.onDeviceParamsUpdated(this.deviceParams); + } + } else { + console.error('Failed to recalculate device parameters.'); + } + }; + Dpdb.prototype.calcDeviceParams_ = function () { + var db = this.dpdb; + if (!db) { + console.error('DPDB not available.'); + return null; + } + if (db.format != 1) { + console.error('DPDB has unexpected format version.'); + return null; + } + if (!db.devices || !db.devices.length) { + console.error('DPDB does not have a devices section.'); + return null; + } + var userAgent = navigator.userAgent || navigator.vendor || window.opera; + var width = getScreenWidth(); + var height = getScreenHeight(); + if (!db.devices) { + console.error('DPDB has no devices section.'); + return null; + } + for (var i = 0; i < db.devices.length; i++) { + var device = db.devices[i]; + if (!device.rules) { + console.warn('Device[' + i + '] has no rules section.'); + continue; + } + if (device.type != 'ios' && device.type != 'android') { + console.warn('Device[' + i + '] has invalid type.'); + continue; + } + if (isIOS() != (device.type == 'ios')) continue; + var matched = false; + for (var j = 0; j < device.rules.length; j++) { + var rule = device.rules[j]; + if (this.matchRule_(rule, userAgent, width, height)) { + matched = true; + break; + } + } + if (!matched) continue; + var xdpi = device.dpi[0] || device.dpi; + var ydpi = device.dpi[1] || device.dpi; + return new DeviceParams({ xdpi: xdpi, ydpi: ydpi, bevelMm: device.bw }); + } + console.warn('No DPDB device match.'); + return null; + }; + Dpdb.prototype.matchRule_ = function (rule, ua, screenWidth, screenHeight) { + if (!rule.ua && !rule.res) return false; + if (rule.ua && ua.indexOf(rule.ua) < 0) return false; + if (rule.res) { + if (!rule.res[0] || !rule.res[1]) return false; + var resX = rule.res[0]; + var resY = rule.res[1]; + if (Math.min(screenWidth, screenHeight) != Math.min(resX, resY) || Math.max(screenWidth, screenHeight) != Math.max(resX, resY)) { + return false; + } + } + return true; + }; + function DeviceParams(params) { + this.xdpi = params.xdpi; + this.ydpi = params.ydpi; + this.bevelMm = params.bevelMm; + } + function SensorSample(sample, timestampS) { + this.set(sample, timestampS); + } + SensorSample.prototype.set = function (sample, timestampS) { + this.sample = sample; + this.timestampS = timestampS; + }; + SensorSample.prototype.copy = function (sensorSample) { + this.set(sensorSample.sample, sensorSample.timestampS); + }; + function ComplementaryFilter(kFilter, isDebug) { + this.kFilter = kFilter; + this.isDebug = isDebug; + this.currentAccelMeasurement = new SensorSample(); + this.currentGyroMeasurement = new SensorSample(); + this.previousGyroMeasurement = new SensorSample(); + if (isIOS()) { + this.filterQ = new Quaternion(-1, 0, 0, 1); + } else { + this.filterQ = new Quaternion(1, 0, 0, 1); + } + this.previousFilterQ = new Quaternion(); + this.previousFilterQ.copy(this.filterQ); + this.accelQ = new Quaternion(); + this.isOrientationInitialized = false; + this.estimatedGravity = new Vector3(); + this.measuredGravity = new Vector3(); + this.gyroIntegralQ = new Quaternion(); + } + ComplementaryFilter.prototype.addAccelMeasurement = function (vector, timestampS) { + this.currentAccelMeasurement.set(vector, timestampS); + }; + ComplementaryFilter.prototype.addGyroMeasurement = function (vector, timestampS) { + this.currentGyroMeasurement.set(vector, timestampS); + var deltaT = timestampS - this.previousGyroMeasurement.timestampS; + if (isTimestampDeltaValid(deltaT)) { + this.run_(); + } + this.previousGyroMeasurement.copy(this.currentGyroMeasurement); + }; + ComplementaryFilter.prototype.run_ = function () { + if (!this.isOrientationInitialized) { + this.accelQ = this.accelToQuaternion_(this.currentAccelMeasurement.sample); + this.previousFilterQ.copy(this.accelQ); + this.isOrientationInitialized = true; + return; + } + var deltaT = this.currentGyroMeasurement.timestampS - this.previousGyroMeasurement.timestampS; + var gyroDeltaQ = this.gyroToQuaternionDelta_(this.currentGyroMeasurement.sample, deltaT); + this.gyroIntegralQ.multiply(gyroDeltaQ); + this.filterQ.copy(this.previousFilterQ); + this.filterQ.multiply(gyroDeltaQ); + var invFilterQ = new Quaternion(); + invFilterQ.copy(this.filterQ); + invFilterQ.inverse(); + this.estimatedGravity.set(0, 0, -1); + this.estimatedGravity.applyQuaternion(invFilterQ); + this.estimatedGravity.normalize(); + this.measuredGravity.copy(this.currentAccelMeasurement.sample); + this.measuredGravity.normalize(); + var deltaQ = new Quaternion(); + deltaQ.setFromUnitVectors(this.estimatedGravity, this.measuredGravity); + deltaQ.inverse(); + if (this.isDebug) { + console.log('Delta: %d deg, G_est: (%s, %s, %s), G_meas: (%s, %s, %s)', radToDeg * getQuaternionAngle(deltaQ), this.estimatedGravity.x.toFixed(1), this.estimatedGravity.y.toFixed(1), this.estimatedGravity.z.toFixed(1), this.measuredGravity.x.toFixed(1), this.measuredGravity.y.toFixed(1), this.measuredGravity.z.toFixed(1)); + } + var targetQ = new Quaternion(); + targetQ.copy(this.filterQ); + targetQ.multiply(deltaQ); + this.filterQ.slerp(targetQ, 1 - this.kFilter); + this.previousFilterQ.copy(this.filterQ); + }; + ComplementaryFilter.prototype.getOrientation = function () { + return this.filterQ; + }; + ComplementaryFilter.prototype.accelToQuaternion_ = function (accel) { + var normAccel = new Vector3(); + normAccel.copy(accel); + normAccel.normalize(); + var quat = new Quaternion(); + quat.setFromUnitVectors(new Vector3(0, 0, -1), normAccel); + quat.inverse(); + return quat; + }; + ComplementaryFilter.prototype.gyroToQuaternionDelta_ = function (gyro, dt) { + var quat = new Quaternion(); + var axis = new Vector3(); + axis.copy(gyro); + axis.normalize(); + quat.setFromAxisAngle(axis, gyro.length() * dt); + return quat; + }; + function PosePredictor(predictionTimeS, isDebug) { + this.predictionTimeS = predictionTimeS; + this.isDebug = isDebug; + this.previousQ = new Quaternion(); + this.previousTimestampS = null; + this.deltaQ = new Quaternion(); + this.outQ = new Quaternion(); + } + PosePredictor.prototype.getPrediction = function (currentQ, gyro, timestampS) { + if (!this.previousTimestampS) { + this.previousQ.copy(currentQ); + this.previousTimestampS = timestampS; + return currentQ; + } + var axis = new Vector3(); + axis.copy(gyro); + axis.normalize(); + var angularSpeed = gyro.length(); + if (angularSpeed < degToRad * 20) { + if (this.isDebug) { + console.log('Moving slowly, at %s deg/s: no prediction', (radToDeg * angularSpeed).toFixed(1)); + } + this.outQ.copy(currentQ); + this.previousQ.copy(currentQ); + return this.outQ; + } + var predictAngle = angularSpeed * this.predictionTimeS; + this.deltaQ.setFromAxisAngle(axis, predictAngle); + this.outQ.copy(this.previousQ); + this.outQ.multiply(this.deltaQ); + this.previousQ.copy(currentQ); + this.previousTimestampS = timestampS; + return this.outQ; + }; + function FusionPoseSensor(kFilter, predictionTime, yawOnly, isDebug) { + this.yawOnly = yawOnly; + this.accelerometer = new Vector3(); + this.gyroscope = new Vector3(); + this.filter = new ComplementaryFilter(kFilter, isDebug); + this.posePredictor = new PosePredictor(predictionTime, isDebug); + this.isFirefoxAndroid = isFirefoxAndroid(); + this.isIOS = isIOS(); + var chromeVersion = getChromeVersion(); + this.isDeviceMotionInRadians = !this.isIOS && chromeVersion && chromeVersion < 66; + this.isWithoutDeviceMotion = isChromeWithoutDeviceMotion(); + this.filterToWorldQ = new Quaternion(); + if (isIOS()) { + this.filterToWorldQ.setFromAxisAngle(new Vector3(1, 0, 0), Math.PI / 2); + } else { + this.filterToWorldQ.setFromAxisAngle(new Vector3(1, 0, 0), -Math.PI / 2); + } + this.inverseWorldToScreenQ = new Quaternion(); + this.worldToScreenQ = new Quaternion(); + this.originalPoseAdjustQ = new Quaternion(); + this.originalPoseAdjustQ.setFromAxisAngle(new Vector3(0, 0, 1), -window.orientation * Math.PI / 180); + this.setScreenTransform_(); + if (isLandscapeMode()) { + this.filterToWorldQ.multiply(this.inverseWorldToScreenQ); + } + this.resetQ = new Quaternion(); + this.orientationOut_ = new Float32Array(4); + this.start(); + } + FusionPoseSensor.prototype.getPosition = function () { + return null; + }; + FusionPoseSensor.prototype.getOrientation = function () { + var orientation = void 0; + if (this.isWithoutDeviceMotion && this._deviceOrientationQ) { + this.deviceOrientationFixQ = this.deviceOrientationFixQ || function () { + var z = new Quaternion().setFromAxisAngle(new Vector3(0, 0, -1), 0); + var y = new Quaternion(); + if (window.orientation === -90) { + y.setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / -2); + } else { + y.setFromAxisAngle(new Vector3(0, 1, 0), Math.PI / 2); + } + return z.multiply(y); + }(); + this.deviceOrientationFilterToWorldQ = this.deviceOrientationFilterToWorldQ || function () { + var q = new Quaternion(); + q.setFromAxisAngle(new Vector3(1, 0, 0), -Math.PI / 2); + return q; + }(); + orientation = this._deviceOrientationQ; + var out = new Quaternion(); + out.copy(orientation); + out.multiply(this.deviceOrientationFilterToWorldQ); + out.multiply(this.resetQ); + out.multiply(this.worldToScreenQ); + out.multiplyQuaternions(this.deviceOrientationFixQ, out); + if (this.yawOnly) { + out.x = 0; + out.z = 0; + out.normalize(); + } + this.orientationOut_[0] = out.x; + this.orientationOut_[1] = out.y; + this.orientationOut_[2] = out.z; + this.orientationOut_[3] = out.w; + return this.orientationOut_; + } else { + var filterOrientation = this.filter.getOrientation(); + orientation = this.posePredictor.getPrediction(filterOrientation, this.gyroscope, this.previousTimestampS); + } + var out = new Quaternion(); + out.copy(this.filterToWorldQ); + out.multiply(this.resetQ); + out.multiply(orientation); + out.multiply(this.worldToScreenQ); + if (this.yawOnly) { + out.x = 0; + out.z = 0; + out.normalize(); + } + this.orientationOut_[0] = out.x; + this.orientationOut_[1] = out.y; + this.orientationOut_[2] = out.z; + this.orientationOut_[3] = out.w; + return this.orientationOut_; + }; + FusionPoseSensor.prototype.resetPose = function () { + this.resetQ.copy(this.filter.getOrientation()); + this.resetQ.x = 0; + this.resetQ.y = 0; + this.resetQ.z *= -1; + this.resetQ.normalize(); + if (isLandscapeMode()) { + this.resetQ.multiply(this.inverseWorldToScreenQ); + } + this.resetQ.multiply(this.originalPoseAdjustQ); + }; + FusionPoseSensor.prototype.onDeviceOrientation_ = function (e) { + this._deviceOrientationQ = this._deviceOrientationQ || new Quaternion(); + var alpha = e.alpha, + beta = e.beta, + gamma = e.gamma; + alpha = (alpha || 0) * Math.PI / 180; + beta = (beta || 0) * Math.PI / 180; + gamma = (gamma || 0) * Math.PI / 180; + this._deviceOrientationQ.setFromEulerYXZ(beta, alpha, -gamma); + }; + FusionPoseSensor.prototype.onDeviceMotion_ = function (deviceMotion) { + this.updateDeviceMotion_(deviceMotion); + }; + FusionPoseSensor.prototype.updateDeviceMotion_ = function (deviceMotion) { + var accGravity = deviceMotion.accelerationIncludingGravity; + var rotRate = deviceMotion.rotationRate; + var timestampS = deviceMotion.timeStamp / 1000; + var deltaS = timestampS - this.previousTimestampS; + if (deltaS < 0) { + warnOnce('fusion-pose-sensor:invalid:non-monotonic', 'Invalid timestamps detected: non-monotonic timestamp from devicemotion'); + this.previousTimestampS = timestampS; + return; + } else if (deltaS <= MIN_TIMESTEP || deltaS > MAX_TIMESTEP) { + warnOnce('fusion-pose-sensor:invalid:outside-threshold', 'Invalid timestamps detected: Timestamp from devicemotion outside expected range.'); + this.previousTimestampS = timestampS; + return; + } + this.accelerometer.set(-accGravity.x, -accGravity.y, -accGravity.z); + if (isR7()) { + this.gyroscope.set(-rotRate.beta, rotRate.alpha, rotRate.gamma); + } else { + this.gyroscope.set(rotRate.alpha, rotRate.beta, rotRate.gamma); + } + if (!this.isDeviceMotionInRadians) { + this.gyroscope.multiplyScalar(Math.PI / 180); + } + this.filter.addAccelMeasurement(this.accelerometer, timestampS); + this.filter.addGyroMeasurement(this.gyroscope, timestampS); + this.previousTimestampS = timestampS; + }; + FusionPoseSensor.prototype.onOrientationChange_ = function (screenOrientation) { + this.setScreenTransform_(); + }; + FusionPoseSensor.prototype.onMessage_ = function (event) { + var message = event.data; + if (!message || !message.type) { + return; + } + var type = message.type.toLowerCase(); + if (type !== 'devicemotion') { + return; + } + this.updateDeviceMotion_(message.deviceMotionEvent); + }; + FusionPoseSensor.prototype.setScreenTransform_ = function () { + this.worldToScreenQ.set(0, 0, 0, 1); + switch (window.orientation) { + case 0: + break; + case 90: + this.worldToScreenQ.setFromAxisAngle(new Vector3(0, 0, 1), -Math.PI / 2); + break; + case -90: + this.worldToScreenQ.setFromAxisAngle(new Vector3(0, 0, 1), Math.PI / 2); + break; + case 180: + break; + } + this.inverseWorldToScreenQ.copy(this.worldToScreenQ); + this.inverseWorldToScreenQ.inverse(); + }; + FusionPoseSensor.prototype.start = function () { + this.onDeviceMotionCallback_ = this.onDeviceMotion_.bind(this); + this.onOrientationChangeCallback_ = this.onOrientationChange_.bind(this); + this.onMessageCallback_ = this.onMessage_.bind(this); + this.onDeviceOrientationCallback_ = this.onDeviceOrientation_.bind(this); + if (isIOS() && isInsideCrossOriginIFrame()) { + window.addEventListener('message', this.onMessageCallback_); + } + window.addEventListener('orientationchange', this.onOrientationChangeCallback_); + if (this.isWithoutDeviceMotion) { + window.addEventListener('deviceorientation', this.onDeviceOrientationCallback_); + } else { + window.addEventListener('devicemotion', this.onDeviceMotionCallback_); + } + }; + FusionPoseSensor.prototype.stop = function () { + window.removeEventListener('devicemotion', this.onDeviceMotionCallback_); + window.removeEventListener('deviceorientation', this.onDeviceOrientationCallback_); + window.removeEventListener('orientationchange', this.onOrientationChangeCallback_); + window.removeEventListener('message', this.onMessageCallback_); + }; + var SENSOR_FREQUENCY = 60; + var X_AXIS = new Vector3(1, 0, 0); + var Z_AXIS = new Vector3(0, 0, 1); + var orientation = {}; + if (screen.orientation) { + orientation = screen.orientation; + } else if (screen.msOrientation) { + orientation = screen.msOrientation; + } else { + Object.defineProperty(orientation, 'angle', { + get: function get$$1() { + return window.orientation || 0; + } + }); + } + var SENSOR_TO_VR = new Quaternion(); + SENSOR_TO_VR.setFromAxisAngle(X_AXIS, -Math.PI / 2); + SENSOR_TO_VR.multiply(new Quaternion().setFromAxisAngle(Z_AXIS, Math.PI / 2)); + var PoseSensor = function () { + function PoseSensor(config) { + classCallCheck(this, PoseSensor); + this.config = config; + this.sensor = null; + this.fusionSensor = null; + this._out = new Float32Array(4); + this.api = null; + this.errors = []; + this._sensorQ = new Quaternion(); + this._worldToScreenQ = new Quaternion(); + this._outQ = new Quaternion(); + this._onSensorRead = this._onSensorRead.bind(this); + this._onSensorError = this._onSensorError.bind(this); + this._onOrientationChange = this._onOrientationChange.bind(this); + this._onOrientationChange(); + this.init(); + } + createClass(PoseSensor, [{ + key: 'init', + value: function init() { + var sensor = null; + try { + sensor = new RelativeOrientationSensor({ frequency: SENSOR_FREQUENCY }); + sensor.addEventListener('error', this._onSensorError); + } catch (error) { + this.errors.push(error); + if (error.name === 'SecurityError') { + console.error('Cannot construct sensors due to the Feature Policy'); + console.warn('Attempting to fall back using "devicemotion"; however this will ' + 'fail in the future without correct permissions.'); + this.useDeviceMotion(); + } else if (error.name === 'ReferenceError') { + this.useDeviceMotion(); + } else { + console.error(error); + } + } + if (sensor) { + this.api = 'sensor'; + this.sensor = sensor; + this.sensor.addEventListener('reading', this._onSensorRead); + this.sensor.start(); + } + window.addEventListener('orientationchange', this._onOrientationChange); + } + }, { + key: 'useDeviceMotion', + value: function useDeviceMotion() { + this.api = 'devicemotion'; + this.fusionSensor = new FusionPoseSensor(this.config.K_FILTER, this.config.PREDICTION_TIME_S, this.config.YAW_ONLY, this.config.DEBUG); + if (this.sensor) { + this.sensor.removeEventListener('reading', this._onSensorRead); + this.sensor.removeEventListener('error', this._onSensorError); + this.sensor = null; + } + } + }, { + key: 'getOrientation', + value: function getOrientation() { + if (this.fusionSensor) { + return this.fusionSensor.getOrientation(); + } + if (!this.sensor || !this.sensor.quaternion) { + this._out[0] = this._out[1] = this._out[2] = 0; + this._out[3] = 1; + return this._out; + } + var q = this.sensor.quaternion; + this._sensorQ.set(q[0], q[1], q[2], q[3]); + var out = this._outQ; + out.copy(SENSOR_TO_VR); + out.multiply(this._sensorQ); + out.multiply(this._worldToScreenQ); + if (this.config.YAW_ONLY) { + out.x = out.z = 0; + out.normalize(); + } + this._out[0] = out.x; + this._out[1] = out.y; + this._out[2] = out.z; + this._out[3] = out.w; + return this._out; + } + }, { + key: '_onSensorError', + value: function _onSensorError(event) { + this.errors.push(event.error); + if (event.error.name === 'NotAllowedError') { + console.error('Permission to access sensor was denied'); + } else if (event.error.name === 'NotReadableError') { + console.error('Sensor could not be read'); + } else { + console.error(event.error); + } + this.useDeviceMotion(); + } + }, { + key: '_onSensorRead', + value: function _onSensorRead() {} + }, { + key: '_onOrientationChange', + value: function _onOrientationChange() { + var angle = -orientation.angle * Math.PI / 180; + this._worldToScreenQ.setFromAxisAngle(Z_AXIS, angle); + } + }]); + return PoseSensor; + }(); + var rotateInstructionsAsset = 'PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjE5OHB4IiBoZWlnaHQ9IjI0MHB4IiB2aWV3Qm94PSIwIDAgMTk4IDI0MCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWxuczpza2V0Y2g9Imh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaC9ucyI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDMuMy4zICgxMjA4MSkgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+CiAgICA8dGl0bGU+dHJhbnNpdGlvbjwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHNrZXRjaDp0eXBlPSJNU1BhZ2UiPgogICAgICAgIDxnIGlkPSJ0cmFuc2l0aW9uIiBza2V0Y2g6dHlwZT0iTVNBcnRib2FyZEdyb3VwIj4KICAgICAgICAgICAgPGcgaWQ9IkltcG9ydGVkLUxheWVycy1Db3B5LTQtKy1JbXBvcnRlZC1MYXllcnMtQ29weS0rLUltcG9ydGVkLUxheWVycy1Db3B5LTItQ29weSIgc2tldGNoOnR5cGU9Ik1TTGF5ZXJHcm91cCI+CiAgICAgICAgICAgICAgICA8ZyBpZD0iSW1wb3J0ZWQtTGF5ZXJzLUNvcHktNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDAwMDAsIDEwNy4wMDAwMDApIiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTQ5LjYyNSwyLjUyNyBDMTQ5LjYyNSwyLjUyNyAxNTUuODA1LDYuMDk2IDE1Ni4zNjIsNi40MTggTDE1Ni4zNjIsNy4zMDQgQzE1Ni4zNjIsNy40ODEgMTU2LjM3NSw3LjY2NCAxNTYuNCw3Ljg1MyBDMTU2LjQxLDcuOTM0IDE1Ni40Miw4LjAxNSAxNTYuNDI3LDguMDk1IEMxNTYuNTY3LDkuNTEgMTU3LjQwMSwxMS4wOTMgMTU4LjUzMiwxMi4wOTQgTDE2NC4yNTIsMTcuMTU2IEwxNjQuMzMzLDE3LjA2NiBDMTY0LjMzMywxNy4wNjYgMTY4LjcxNSwxNC41MzYgMTY5LjU2OCwxNC4wNDIgQzE3MS4wMjUsMTQuODgzIDE5NS41MzgsMjkuMDM1IDE5NS41MzgsMjkuMDM1IEwxOTUuNTM4LDgzLjAzNiBDMTk1LjUzOCw4My44MDcgMTk1LjE1Miw4NC4yNTMgMTk0LjU5LDg0LjI1MyBDMTk0LjM1Nyw4NC4yNTMgMTk0LjA5NSw4NC4xNzcgMTkzLjgxOCw4NC4wMTcgTDE2OS44NTEsNzAuMTc5IEwxNjkuODM3LDcwLjIwMyBMMTQyLjUxNSw4NS45NzggTDE0MS42NjUsODQuNjU1IEMxMzYuOTM0LDgzLjEyNiAxMzEuOTE3LDgxLjkxNSAxMjYuNzE0LDgxLjA0NSBDMTI2LjcwOSw4MS4wNiAxMjYuNzA3LDgxLjA2OSAxMjYuNzA3LDgxLjA2OSBMMTIxLjY0LDk4LjAzIEwxMTMuNzQ5LDEwMi41ODYgTDExMy43MTIsMTAyLjUyMyBMMTEzLjcxMiwxMzAuMTEzIEMxMTMuNzEyLDEzMC44ODUgMTEzLjMyNiwxMzEuMzMgMTEyLjc2NCwxMzEuMzMgQzExMi41MzIsMTMxLjMzIDExMi4yNjksMTMxLjI1NCAxMTEuOTkyLDEzMS4wOTQgTDY5LjUxOSwxMDYuNTcyIEM2OC41NjksMTA2LjAyMyA2Ny43OTksMTA0LjY5NSA2Ny43OTksMTAzLjYwNSBMNjcuNzk5LDEwMi41NyBMNjcuNzc4LDEwMi42MTcgQzY3LjI3LDEwMi4zOTMgNjYuNjQ4LDEwMi4yNDkgNjUuOTYyLDEwMi4yMTggQzY1Ljg3NSwxMDIuMjE0IDY1Ljc4OCwxMDIuMjEyIDY1LjcwMSwxMDIuMjEyIEM2NS42MDYsMTAyLjIxMiA2NS41MTEsMTAyLjIxNSA2NS40MTYsMTAyLjIxOSBDNjUuMTk1LDEwMi4yMjkgNjQuOTc0LDEwMi4yMzUgNjQuNzU0LDEwMi4yMzUgQzY0LjMzMSwxMDIuMjM1IDYzLjkxMSwxMDIuMjE2IDYzLjQ5OCwxMDIuMTc4IEM2MS44NDMsMTAyLjAyNSA2MC4yOTgsMTAxLjU3OCA1OS4wOTQsMTAwLjg4MiBMMTIuNTE4LDczLjk5MiBMMTIuNTIzLDc0LjAwNCBMMi4yNDUsNTUuMjU0IEMxLjI0NCw1My40MjcgMi4wMDQsNTEuMDM4IDMuOTQzLDQ5LjkxOCBMNTkuOTU0LDE3LjU3MyBDNjAuNjI2LDE3LjE4NSA2MS4zNSwxNy4wMDEgNjIuMDUzLDE3LjAwMSBDNjMuMzc5LDE3LjAwMSA2NC42MjUsMTcuNjYgNjUuMjgsMTguODU0IEw2NS4yODUsMTguODUxIEw2NS41MTIsMTkuMjY0IEw2NS41MDYsMTkuMjY4IEM2NS45MDksMjAuMDAzIDY2LjQwNSwyMC42OCA2Ni45ODMsMjEuMjg2IEw2Ny4yNiwyMS41NTYgQzY5LjE3NCwyMy40MDYgNzEuNzI4LDI0LjM1NyA3NC4zNzMsMjQuMzU3IEM3Ni4zMjIsMjQuMzU3IDc4LjMyMSwyMy44NCA4MC4xNDgsMjIuNzg1IEM4MC4xNjEsMjIuNzg1IDg3LjQ2NywxOC41NjYgODcuNDY3LDE4LjU2NiBDODguMTM5LDE4LjE3OCA4OC44NjMsMTcuOTk0IDg5LjU2NiwxNy45OTQgQzkwLjg5MiwxNy45OTQgOTIuMTM4LDE4LjY1MiA5Mi43OTIsMTkuODQ3IEw5Ni4wNDIsMjUuNzc1IEw5Ni4wNjQsMjUuNzU3IEwxMDIuODQ5LDI5LjY3NCBMMTAyLjc0NCwyOS40OTIgTDE0OS42MjUsMi41MjcgTTE0OS42MjUsMC44OTIgQzE0OS4zNDMsMC44OTIgMTQ5LjA2MiwwLjk2NSAxNDguODEsMS4xMSBMMTAyLjY0MSwyNy42NjYgTDk3LjIzMSwyNC41NDIgTDk0LjIyNiwxOS4wNjEgQzkzLjMxMywxNy4zOTQgOTEuNTI3LDE2LjM1OSA4OS41NjYsMTYuMzU4IEM4OC41NTUsMTYuMzU4IDg3LjU0NiwxNi42MzIgODYuNjQ5LDE3LjE1IEM4My44NzgsMTguNzUgNzkuNjg3LDIxLjE2OSA3OS4zNzQsMjEuMzQ1IEM3OS4zNTksMjEuMzUzIDc5LjM0NSwyMS4zNjEgNzkuMzMsMjEuMzY5IEM3Ny43OTgsMjIuMjU0IDc2LjA4NCwyMi43MjIgNzQuMzczLDIyLjcyMiBDNzIuMDgxLDIyLjcyMiA2OS45NTksMjEuODkgNjguMzk3LDIwLjM4IEw2OC4xNDUsMjAuMTM1IEM2Ny43MDYsMTkuNjcyIDY3LjMyMywxOS4xNTYgNjcuMDA2LDE4LjYwMSBDNjYuOTg4LDE4LjU1OSA2Ni45NjgsMTguNTE5IDY2Ljk0NiwxOC40NzkgTDY2LjcxOSwxOC4wNjUgQzY2LjY5LDE4LjAxMiA2Ni42NTgsMTcuOTYgNjYuNjI0LDE3LjkxMSBDNjUuNjg2LDE2LjMzNyA2My45NTEsMTUuMzY2IDYyLjA1MywxNS4zNjYgQzYxLjA0MiwxNS4zNjYgNjAuMDMzLDE1LjY0IDU5LjEzNiwxNi4xNTggTDMuMTI1LDQ4LjUwMiBDMC40MjYsNTAuMDYxIC0wLjYxMyw1My40NDIgMC44MTEsNTYuMDQgTDExLjA4OSw3NC43OSBDMTEuMjY2LDc1LjExMyAxMS41MzcsNzUuMzUzIDExLjg1LDc1LjQ5NCBMNTguMjc2LDEwMi4yOTggQzU5LjY3OSwxMDMuMTA4IDYxLjQzMywxMDMuNjMgNjMuMzQ4LDEwMy44MDYgQzYzLjgxMiwxMDMuODQ4IDY0LjI4NSwxMDMuODcgNjQuNzU0LDEwMy44NyBDNjUsMTAzLjg3IDY1LjI0OSwxMDMuODY0IDY1LjQ5NCwxMDMuODUyIEM2NS41NjMsMTAzLjg0OSA2NS42MzIsMTAzLjg0NyA2NS43MDEsMTAzLjg0NyBDNjUuNzY0LDEwMy44NDcgNjUuODI4LDEwMy44NDkgNjUuODksMTAzLjg1MiBDNjUuOTg2LDEwMy44NTYgNjYuMDgsMTAzLjg2MyA2Ni4xNzMsMTAzLjg3NCBDNjYuMjgyLDEwNS40NjcgNjcuMzMyLDEwNy4xOTcgNjguNzAyLDEwNy45ODggTDExMS4xNzQsMTMyLjUxIEMxMTEuNjk4LDEzMi44MTIgMTEyLjIzMiwxMzIuOTY1IDExMi43NjQsMTMyLjk2NSBDMTE0LjI2MSwxMzIuOTY1IDExNS4zNDcsMTMxLjc2NSAxMTUuMzQ3LDEzMC4xMTMgTDExNS4zNDcsMTAzLjU1MSBMMTIyLjQ1OCw5OS40NDYgQzEyMi44MTksOTkuMjM3IDEyMy4wODcsOTguODk4IDEyMy4yMDcsOTguNDk4IEwxMjcuODY1LDgyLjkwNSBDMTMyLjI3OSw4My43MDIgMTM2LjU1Nyw4NC43NTMgMTQwLjYwNyw4Ni4wMzMgTDE0MS4xNCw4Ni44NjIgQzE0MS40NTEsODcuMzQ2IDE0MS45NzcsODcuNjEzIDE0Mi41MTYsODcuNjEzIEMxNDIuNzk0LDg3LjYxMyAxNDMuMDc2LDg3LjU0MiAxNDMuMzMzLDg3LjM5MyBMMTY5Ljg2NSw3Mi4wNzYgTDE5Myw4NS40MzMgQzE5My41MjMsODUuNzM1IDE5NC4wNTgsODUuODg4IDE5NC41OSw4NS44ODggQzE5Ni4wODcsODUuODg4IDE5Ny4xNzMsODQuNjg5IDE5Ny4xNzMsODMuMDM2IEwxOTcuMTczLDI5LjAzNSBDMTk3LjE3MywyOC40NTEgMTk2Ljg2MSwyNy45MTEgMTk2LjM1NSwyNy42MTkgQzE5Ni4zNTUsMjcuNjE5IDE3MS44NDMsMTMuNDY3IDE3MC4zODUsMTIuNjI2IEMxNzAuMTMyLDEyLjQ4IDE2OS44NSwxMi40MDcgMTY5LjU2OCwxMi40MDcgQzE2OS4yODUsMTIuNDA3IDE2OS4wMDIsMTIuNDgxIDE2OC43NDksMTIuNjI3IEMxNjguMTQzLDEyLjk3OCAxNjUuNzU2LDE0LjM1NyAxNjQuNDI0LDE1LjEyNSBMMTU5LjYxNSwxMC44NyBDMTU4Ljc5NiwxMC4xNDUgMTU4LjE1NCw4LjkzNyAxNTguMDU0LDcuOTM0IEMxNTguMDQ1LDcuODM3IDE1OC4wMzQsNy43MzkgMTU4LjAyMSw3LjY0IEMxNTguMDA1LDcuNTIzIDE1Ny45OTgsNy40MSAxNTcuOTk4LDcuMzA0IEwxNTcuOTk4LDYuNDE4IEMxNTcuOTk4LDUuODM0IDE1Ny42ODYsNS4yOTUgMTU3LjE4MSw1LjAwMiBDMTU2LjYyNCw0LjY4IDE1MC40NDIsMS4xMTEgMTUwLjQ0MiwxLjExMSBDMTUwLjE4OSwwLjk2NSAxNDkuOTA3LDAuODkyIDE0OS42MjUsMC44OTIiIGlkPSJGaWxsLTEiIGZpbGw9IiM0NTVBNjQiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNOTYuMDI3LDI1LjYzNiBMMTQyLjYwMyw1Mi41MjcgQzE0My44MDcsNTMuMjIyIDE0NC41ODIsNTQuMTE0IDE0NC44NDUsNTUuMDY4IEwxNDQuODM1LDU1LjA3NSBMNjMuNDYxLDEwMi4wNTcgTDYzLjQ2LDEwMi4wNTcgQzYxLjgwNiwxMDEuOTA1IDYwLjI2MSwxMDEuNDU3IDU5LjA1NywxMDAuNzYyIEwxMi40ODEsNzMuODcxIEw5Ni4wMjcsMjUuNjM2IiBpZD0iRmlsbC0yIiBmaWxsPSIjRkFGQUZBIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTYzLjQ2MSwxMDIuMTc0IEM2My40NTMsMTAyLjE3NCA2My40NDYsMTAyLjE3NCA2My40MzksMTAyLjE3MiBDNjEuNzQ2LDEwMi4wMTYgNjAuMjExLDEwMS41NjMgNTguOTk4LDEwMC44NjMgTDEyLjQyMiw3My45NzMgQzEyLjM4Niw3My45NTIgMTIuMzY0LDczLjkxNCAxMi4zNjQsNzMuODcxIEMxMi4zNjQsNzMuODMgMTIuMzg2LDczLjc5MSAxMi40MjIsNzMuNzcgTDk1Ljk2OCwyNS41MzUgQzk2LjAwNCwyNS41MTQgOTYuMDQ5LDI1LjUxNCA5Ni4wODUsMjUuNTM1IEwxNDIuNjYxLDUyLjQyNiBDMTQzLjg4OCw1My4xMzQgMTQ0LjY4Miw1NC4wMzggMTQ0Ljk1Nyw1NS4wMzcgQzE0NC45Nyw1NS4wODMgMTQ0Ljk1Myw1NS4xMzMgMTQ0LjkxNSw1NS4xNjEgQzE0NC45MTEsNTUuMTY1IDE0NC44OTgsNTUuMTc0IDE0NC44OTQsNTUuMTc3IEw2My41MTksMTAyLjE1OCBDNjMuNTAxLDEwMi4xNjkgNjMuNDgxLDEwMi4xNzQgNjMuNDYxLDEwMi4xNzQgTDYzLjQ2MSwxMDIuMTc0IFogTTEyLjcxNCw3My44NzEgTDU5LjExNSwxMDAuNjYxIEM2MC4yOTMsMTAxLjM0MSA2MS43ODYsMTAxLjc4MiA2My40MzUsMTAxLjkzNyBMMTQ0LjcwNyw1NS4wMTUgQzE0NC40MjgsNTQuMTA4IDE0My42ODIsNTMuMjg1IDE0Mi41NDQsNTIuNjI4IEw5Ni4wMjcsMjUuNzcxIEwxMi43MTQsNzMuODcxIEwxMi43MTQsNzMuODcxIFoiIGlkPSJGaWxsLTMiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTQ4LjMyNyw1OC40NzEgQzE0OC4xNDUsNTguNDggMTQ3Ljk2Miw1OC40OCAxNDcuNzgxLDU4LjQ3MiBDMTQ1Ljg4Nyw1OC4zODkgMTQ0LjQ3OSw1Ny40MzQgMTQ0LjYzNiw1Ni4zNCBDMTQ0LjY4OSw1NS45NjcgMTQ0LjY2NCw1NS41OTcgMTQ0LjU2NCw1NS4yMzUgTDYzLjQ2MSwxMDIuMDU3IEM2NC4wODksMTAyLjExNSA2NC43MzMsMTAyLjEzIDY1LjM3OSwxMDIuMDk5IEM2NS41NjEsMTAyLjA5IDY1Ljc0MywxMDIuMDkgNjUuOTI1LDEwMi4wOTggQzY3LjgxOSwxMDIuMTgxIDY5LjIyNywxMDMuMTM2IDY5LjA3LDEwNC4yMyBMMTQ4LjMyNyw1OC40NzEiIGlkPSJGaWxsLTQiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNjkuMDcsMTA0LjM0NyBDNjkuMDQ4LDEwNC4zNDcgNjkuMDI1LDEwNC4zNCA2OS4wMDUsMTA0LjMyNyBDNjguOTY4LDEwNC4zMDEgNjguOTQ4LDEwNC4yNTcgNjguOTU1LDEwNC4yMTMgQzY5LDEwMy44OTYgNjguODk4LDEwMy41NzYgNjguNjU4LDEwMy4yODggQzY4LjE1MywxMDIuNjc4IDY3LjEwMywxMDIuMjY2IDY1LjkyLDEwMi4yMTQgQzY1Ljc0MiwxMDIuMjA2IDY1LjU2MywxMDIuMjA3IDY1LjM4NSwxMDIuMjE1IEM2NC43NDIsMTAyLjI0NiA2NC4wODcsMTAyLjIzMiA2My40NSwxMDIuMTc0IEM2My4zOTksMTAyLjE2OSA2My4zNTgsMTAyLjEzMiA2My4zNDcsMTAyLjA4MiBDNjMuMzM2LDEwMi4wMzMgNjMuMzU4LDEwMS45ODEgNjMuNDAyLDEwMS45NTYgTDE0NC41MDYsNTUuMTM0IEMxNDQuNTM3LDU1LjExNiAxNDQuNTc1LDU1LjExMyAxNDQuNjA5LDU1LjEyNyBDMTQ0LjY0Miw1NS4xNDEgMTQ0LjY2OCw1NS4xNyAxNDQuNjc3LDU1LjIwNCBDMTQ0Ljc4MSw1NS41ODUgMTQ0LjgwNiw1NS45NzIgMTQ0Ljc1MSw1Ni4zNTcgQzE0NC43MDYsNTYuNjczIDE0NC44MDgsNTYuOTk0IDE0NS4wNDcsNTcuMjgyIEMxNDUuNTUzLDU3Ljg5MiAxNDYuNjAyLDU4LjMwMyAxNDcuNzg2LDU4LjM1NSBDMTQ3Ljk2NCw1OC4zNjMgMTQ4LjE0Myw1OC4zNjMgMTQ4LjMyMSw1OC4zNTQgQzE0OC4zNzcsNTguMzUyIDE0OC40MjQsNTguMzg3IDE0OC40MzksNTguNDM4IEMxNDguNDU0LDU4LjQ5IDE0OC40MzIsNTguNTQ1IDE0OC4zODUsNTguNTcyIEw2OS4xMjksMTA0LjMzMSBDNjkuMTExLDEwNC4zNDIgNjkuMDksMTA0LjM0NyA2OS4wNywxMDQuMzQ3IEw2OS4wNywxMDQuMzQ3IFogTTY1LjY2NSwxMDEuOTc1IEM2NS43NTQsMTAxLjk3NSA2NS44NDIsMTAxLjk3NyA2NS45MywxMDEuOTgxIEM2Ny4xOTYsMTAyLjAzNyA2OC4yODMsMTAyLjQ2OSA2OC44MzgsMTAzLjEzOSBDNjkuMDY1LDEwMy40MTMgNjkuMTg4LDEwMy43MTQgNjkuMTk4LDEwNC4wMjEgTDE0Ny44ODMsNTguNTkyIEMxNDcuODQ3LDU4LjU5MiAxNDcuODExLDU4LjU5MSAxNDcuNzc2LDU4LjU4OSBDMTQ2LjUwOSw1OC41MzMgMTQ1LjQyMiw1OC4xIDE0NC44NjcsNTcuNDMxIEMxNDQuNTg1LDU3LjA5MSAxNDQuNDY1LDU2LjcwNyAxNDQuNTIsNTYuMzI0IEMxNDQuNTYzLDU2LjAyMSAxNDQuNTUyLDU1LjcxNiAxNDQuNDg4LDU1LjQxNCBMNjMuODQ2LDEwMS45NyBDNjQuMzUzLDEwMi4wMDIgNjQuODY3LDEwMi4wMDYgNjUuMzc0LDEwMS45ODIgQzY1LjQ3MSwxMDEuOTc3IDY1LjU2OCwxMDEuOTc1IDY1LjY2NSwxMDEuOTc1IEw2NS42NjUsMTAxLjk3NSBaIiBpZD0iRmlsbC01IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTIuMjA4LDU1LjEzNCBDMS4yMDcsNTMuMzA3IDEuOTY3LDUwLjkxNyAzLjkwNiw0OS43OTcgTDU5LjkxNywxNy40NTMgQzYxLjg1NiwxNi4zMzMgNjQuMjQxLDE2LjkwNyA2NS4yNDMsMTguNzM0IEw2NS40NzUsMTkuMTQ0IEM2NS44NzIsMTkuODgyIDY2LjM2OCwyMC41NiA2Ni45NDUsMjEuMTY1IEw2Ny4yMjMsMjEuNDM1IEM3MC41NDgsMjQuNjQ5IDc1LjgwNiwyNS4xNTEgODAuMTExLDIyLjY2NSBMODcuNDMsMTguNDQ1IEM4OS4zNywxNy4zMjYgOTEuNzU0LDE3Ljg5OSA5Mi43NTUsMTkuNzI3IEw5Ni4wMDUsMjUuNjU1IEwxMi40ODYsNzMuODg0IEwyLjIwOCw1NS4xMzQgWiIgaWQ9IkZpbGwtNiIgZmlsbD0iI0ZBRkFGQSI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMi40ODYsNzQuMDAxIEMxMi40NzYsNzQuMDAxIDEyLjQ2NSw3My45OTkgMTIuNDU1LDczLjk5NiBDMTIuNDI0LDczLjk4OCAxMi4zOTksNzMuOTY3IDEyLjM4NCw3My45NCBMMi4xMDYsNTUuMTkgQzEuMDc1LDUzLjMxIDEuODU3LDUwLjg0NSAzLjg0OCw0OS42OTYgTDU5Ljg1OCwxNy4zNTIgQzYwLjUyNSwxNi45NjcgNjEuMjcxLDE2Ljc2NCA2Mi4wMTYsMTYuNzY0IEM2My40MzEsMTYuNzY0IDY0LjY2NiwxNy40NjYgNjUuMzI3LDE4LjY0NiBDNjUuMzM3LDE4LjY1NCA2NS4zNDUsMTguNjYzIDY1LjM1MSwxOC42NzQgTDY1LjU3OCwxOS4wODggQzY1LjU4NCwxOS4xIDY1LjU4OSwxOS4xMTIgNjUuNTkxLDE5LjEyNiBDNjUuOTg1LDE5LjgzOCA2Ni40NjksMjAuNDk3IDY3LjAzLDIxLjA4NSBMNjcuMzA1LDIxLjM1MSBDNjkuMTUxLDIzLjEzNyA3MS42NDksMjQuMTIgNzQuMzM2LDI0LjEyIEM3Ni4zMTMsMjQuMTIgNzguMjksMjMuNTgyIDgwLjA1MywyMi41NjMgQzgwLjA2NCwyMi41NTcgODAuMDc2LDIyLjU1MyA4MC4wODgsMjIuNTUgTDg3LjM3MiwxOC4zNDQgQzg4LjAzOCwxNy45NTkgODguNzg0LDE3Ljc1NiA4OS41MjksMTcuNzU2IEM5MC45NTYsMTcuNzU2IDkyLjIwMSwxOC40NzIgOTIuODU4LDE5LjY3IEw5Ni4xMDcsMjUuNTk5IEM5Ni4xMzgsMjUuNjU0IDk2LjExOCwyNS43MjQgOTYuMDYzLDI1Ljc1NiBMMTIuNTQ1LDczLjk4NSBDMTIuNTI2LDczLjk5NiAxMi41MDYsNzQuMDAxIDEyLjQ4Niw3NC4wMDEgTDEyLjQ4Niw3NC4wMDEgWiBNNjIuMDE2LDE2Ljk5NyBDNjEuMzEyLDE2Ljk5NyA2MC42MDYsMTcuMTkgNTkuOTc1LDE3LjU1NCBMMy45NjUsNDkuODk5IEMyLjA4Myw1MC45ODUgMS4zNDEsNTMuMzA4IDIuMzEsNTUuMDc4IEwxMi41MzEsNzMuNzIzIEw5NS44NDgsMjUuNjExIEw5Mi42NTMsMTkuNzgyIEM5Mi4wMzgsMTguNjYgOTAuODcsMTcuOTkgODkuNTI5LDE3Ljk5IEM4OC44MjUsMTcuOTkgODguMTE5LDE4LjE4MiA4Ny40ODksMTguNTQ3IEw4MC4xNzIsMjIuNzcyIEM4MC4xNjEsMjIuNzc4IDgwLjE0OSwyMi43ODIgODAuMTM3LDIyLjc4NSBDNzguMzQ2LDIzLjgxMSA3Ni4zNDEsMjQuMzU0IDc0LjMzNiwyNC4zNTQgQzcxLjU4OCwyNC4zNTQgNjkuMDMzLDIzLjM0NyA2Ny4xNDIsMjEuNTE5IEw2Ni44NjQsMjEuMjQ5IEM2Ni4yNzcsMjAuNjM0IDY1Ljc3NCwxOS45NDcgNjUuMzY3LDE5LjIwMyBDNjUuMzYsMTkuMTkyIDY1LjM1NiwxOS4xNzkgNjUuMzU0LDE5LjE2NiBMNjUuMTYzLDE4LjgxOSBDNjUuMTU0LDE4LjgxMSA2NS4xNDYsMTguODAxIDY1LjE0LDE4Ljc5IEM2NC41MjUsMTcuNjY3IDYzLjM1NywxNi45OTcgNjIuMDE2LDE2Ljk5NyBMNjIuMDE2LDE2Ljk5NyBaIiBpZD0iRmlsbC03IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTQyLjQzNCw0OC44MDggTDQyLjQzNCw0OC44MDggQzM5LjkyNCw0OC44MDcgMzcuNzM3LDQ3LjU1IDM2LjU4Miw0NS40NDMgQzM0Ljc3MSw0Mi4xMzkgMzYuMTQ0LDM3LjgwOSAzOS42NDEsMzUuNzg5IEw1MS45MzIsMjguNjkxIEM1My4xMDMsMjguMDE1IDU0LjQxMywyNy42NTggNTUuNzIxLDI3LjY1OCBDNTguMjMxLDI3LjY1OCA2MC40MTgsMjguOTE2IDYxLjU3MywzMS4wMjMgQzYzLjM4NCwzNC4zMjcgNjIuMDEyLDM4LjY1NyA1OC41MTQsNDAuNjc3IEw0Ni4yMjMsNDcuNzc1IEM0NS4wNTMsNDguNDUgNDMuNzQyLDQ4LjgwOCA0Mi40MzQsNDguODA4IEw0Mi40MzQsNDguODA4IFogTTU1LjcyMSwyOC4xMjUgQzU0LjQ5NSwyOC4xMjUgNTMuMjY1LDI4LjQ2MSA1Mi4xNjYsMjkuMDk2IEwzOS44NzUsMzYuMTk0IEMzNi41OTYsMzguMDg3IDM1LjMwMiw0Mi4xMzYgMzYuOTkyLDQ1LjIxOCBDMzguMDYzLDQ3LjE3MyA0MC4wOTgsNDguMzQgNDIuNDM0LDQ4LjM0IEM0My42NjEsNDguMzQgNDQuODksNDguMDA1IDQ1Ljk5LDQ3LjM3IEw1OC4yODEsNDAuMjcyIEM2MS41NiwzOC4zNzkgNjIuODUzLDM0LjMzIDYxLjE2NCwzMS4yNDggQzYwLjA5MiwyOS4yOTMgNTguMDU4LDI4LjEyNSA1NS43MjEsMjguMTI1IEw1NS43MjEsMjguMTI1IFoiIGlkPSJGaWxsLTgiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTQ5LjU4OCwyLjQwNyBDMTQ5LjU4OCwyLjQwNyAxNTUuNzY4LDUuOTc1IDE1Ni4zMjUsNi4yOTcgTDE1Ni4zMjUsNy4xODQgQzE1Ni4zMjUsNy4zNiAxNTYuMzM4LDcuNTQ0IDE1Ni4zNjIsNy43MzMgQzE1Ni4zNzMsNy44MTQgMTU2LjM4Miw3Ljg5NCAxNTYuMzksNy45NzUgQzE1Ni41Myw5LjM5IDE1Ny4zNjMsMTAuOTczIDE1OC40OTUsMTEuOTc0IEwxNjUuODkxLDE4LjUxOSBDMTY2LjA2OCwxOC42NzUgMTY2LjI0OSwxOC44MTQgMTY2LjQzMiwxOC45MzQgQzE2OC4wMTEsMTkuOTc0IDE2OS4zODIsMTkuNCAxNjkuNDk0LDE3LjY1MiBDMTY5LjU0MywxNi44NjggMTY5LjU1MSwxNi4wNTcgMTY5LjUxNywxNS4yMjMgTDE2OS41MTQsMTUuMDYzIEwxNjkuNTE0LDEzLjkxMiBDMTcwLjc4LDE0LjY0MiAxOTUuNTAxLDI4LjkxNSAxOTUuNTAxLDI4LjkxNSBMMTk1LjUwMSw4Mi45MTUgQzE5NS41MDEsODQuMDA1IDE5NC43MzEsODQuNDQ1IDE5My43ODEsODMuODk3IEwxNTEuMzA4LDU5LjM3NCBDMTUwLjM1OCw1OC44MjYgMTQ5LjU4OCw1Ny40OTcgMTQ5LjU4OCw1Ni40MDggTDE0OS41ODgsMjIuMzc1IiBpZD0iRmlsbC05IiBmaWxsPSIjRkFGQUZBIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE5NC41NTMsODQuMjUgQzE5NC4yOTYsODQuMjUgMTk0LjAxMyw4NC4xNjUgMTkzLjcyMiw4My45OTcgTDE1MS4yNSw1OS40NzYgQzE1MC4yNjksNTguOTA5IDE0OS40NzEsNTcuNTMzIDE0OS40NzEsNTYuNDA4IEwxNDkuNDcxLDIyLjM3NSBMMTQ5LjcwNSwyMi4zNzUgTDE0OS43MDUsNTYuNDA4IEMxNDkuNzA1LDU3LjQ1OSAxNTAuNDUsNTguNzQ0IDE1MS4zNjYsNTkuMjc0IEwxOTMuODM5LDgzLjc5NSBDMTk0LjI2Myw4NC4wNCAxOTQuNjU1LDg0LjA4MyAxOTQuOTQyLDgzLjkxNyBDMTk1LjIyNyw4My43NTMgMTk1LjM4NCw4My4zOTcgMTk1LjM4NCw4Mi45MTUgTDE5NS4zODQsMjguOTgyIEMxOTQuMTAyLDI4LjI0MiAxNzIuMTA0LDE1LjU0MiAxNjkuNjMxLDE0LjExNCBMMTY5LjYzNCwxNS4yMiBDMTY5LjY2OCwxNi4wNTIgMTY5LjY2LDE2Ljg3NCAxNjkuNjEsMTcuNjU5IEMxNjkuNTU2LDE4LjUwMyAxNjkuMjE0LDE5LjEyMyAxNjguNjQ3LDE5LjQwNSBDMTY4LjAyOCwxOS43MTQgMTY3LjE5NywxOS41NzggMTY2LjM2NywxOS4wMzIgQzE2Ni4xODEsMTguOTA5IDE2NS45OTUsMTguNzY2IDE2NS44MTQsMTguNjA2IEwxNTguNDE3LDEyLjA2MiBDMTU3LjI1OSwxMS4wMzYgMTU2LjQxOCw5LjQzNyAxNTYuMjc0LDcuOTg2IEMxNTYuMjY2LDcuOTA3IDE1Ni4yNTcsNy44MjcgMTU2LjI0Nyw3Ljc0OCBDMTU2LjIyMSw3LjU1NSAxNTYuMjA5LDcuMzY1IDE1Ni4yMDksNy4xODQgTDE1Ni4yMDksNi4zNjQgQzE1NS4zNzUsNS44ODMgMTQ5LjUyOSwyLjUwOCAxNDkuNTI5LDIuNTA4IEwxNDkuNjQ2LDIuMzA2IEMxNDkuNjQ2LDIuMzA2IDE1NS44MjcsNS44NzQgMTU2LjM4NCw2LjE5NiBMMTU2LjQ0Miw2LjIzIEwxNTYuNDQyLDcuMTg0IEMxNTYuNDQyLDcuMzU1IDE1Ni40NTQsNy41MzUgMTU2LjQ3OCw3LjcxNyBDMTU2LjQ4OSw3LjggMTU2LjQ5OSw3Ljg4MiAxNTYuNTA3LDcuOTYzIEMxNTYuNjQ1LDkuMzU4IDE1Ny40NTUsMTAuODk4IDE1OC41NzIsMTEuODg2IEwxNjUuOTY5LDE4LjQzMSBDMTY2LjE0MiwxOC41ODQgMTY2LjMxOSwxOC43MiAxNjYuNDk2LDE4LjgzNyBDMTY3LjI1NCwxOS4zMzYgMTY4LDE5LjQ2NyAxNjguNTQzLDE5LjE5NiBDMTY5LjAzMywxOC45NTMgMTY5LjMyOSwxOC40MDEgMTY5LjM3NywxNy42NDUgQzE2OS40MjcsMTYuODY3IDE2OS40MzQsMTYuMDU0IDE2OS40MDEsMTUuMjI4IEwxNjkuMzk3LDE1LjA2NSBMMTY5LjM5NywxMy43MSBMMTY5LjU3MiwxMy44MSBDMTcwLjgzOSwxNC41NDEgMTk1LjU1OSwyOC44MTQgMTk1LjU1OSwyOC44MTQgTDE5NS42MTgsMjguODQ3IEwxOTUuNjE4LDgyLjkxNSBDMTk1LjYxOCw4My40ODQgMTk1LjQyLDgzLjkxMSAxOTUuMDU5LDg0LjExOSBDMTk0LjkwOCw4NC4yMDYgMTk0LjczNyw4NC4yNSAxOTQuNTUzLDg0LjI1IiBpZD0iRmlsbC0xMCIgZmlsbD0iIzYwN0Q4QiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNDUuNjg1LDU2LjE2MSBMMTY5LjgsNzAuMDgzIEwxNDMuODIyLDg1LjA4MSBMMTQyLjM2LDg0Ljc3NCBDMTM1LjgyNiw4Mi42MDQgMTI4LjczMiw4MS4wNDYgMTIxLjM0MSw4MC4xNTggQzExNi45NzYsNzkuNjM0IDExMi42NzgsODEuMjU0IDExMS43NDMsODMuNzc4IEMxMTEuNTA2LDg0LjQxNCAxMTEuNTAzLDg1LjA3MSAxMTEuNzMyLDg1LjcwNiBDMTEzLjI3LDg5Ljk3MyAxMTUuOTY4LDk0LjA2OSAxMTkuNzI3LDk3Ljg0MSBMMTIwLjI1OSw5OC42ODYgQzEyMC4yNiw5OC42ODUgOTQuMjgyLDExMy42ODMgOTQuMjgyLDExMy42ODMgTDcwLjE2Nyw5OS43NjEgTDE0NS42ODUsNTYuMTYxIiBpZD0iRmlsbC0xMSIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik05NC4yODIsMTEzLjgxOCBMOTQuMjIzLDExMy43ODUgTDY5LjkzMyw5OS43NjEgTDcwLjEwOCw5OS42NiBMMTQ1LjY4NSw1Ni4wMjYgTDE0NS43NDMsNTYuMDU5IEwxNzAuMDMzLDcwLjA4MyBMMTQzLjg0Miw4NS4yMDUgTDE0My43OTcsODUuMTk1IEMxNDMuNzcyLDg1LjE5IDE0Mi4zMzYsODQuODg4IDE0Mi4zMzYsODQuODg4IEMxMzUuNzg3LDgyLjcxNCAxMjguNzIzLDgxLjE2MyAxMjEuMzI3LDgwLjI3NCBDMTIwLjc4OCw4MC4yMDkgMTIwLjIzNiw4MC4xNzcgMTE5LjY4OSw4MC4xNzcgQzExNS45MzEsODAuMTc3IDExMi42MzUsODEuNzA4IDExMS44NTIsODMuODE5IEMxMTEuNjI0LDg0LjQzMiAxMTEuNjIxLDg1LjA1MyAxMTEuODQyLDg1LjY2NyBDMTEzLjM3Nyw4OS45MjUgMTE2LjA1OCw5My45OTMgMTE5LjgxLDk3Ljc1OCBMMTE5LjgyNiw5Ny43NzkgTDEyMC4zNTIsOTguNjE0IEMxMjAuMzU0LDk4LjYxNyAxMjAuMzU2LDk4LjYyIDEyMC4zNTgsOTguNjI0IEwxMjAuNDIyLDk4LjcyNiBMMTIwLjMxNyw5OC43ODcgQzEyMC4yNjQsOTguODE4IDk0LjU5OSwxMTMuNjM1IDk0LjM0LDExMy43ODUgTDk0LjI4MiwxMTMuODE4IEw5NC4yODIsMTEzLjgxOCBaIE03MC40MDEsOTkuNzYxIEw5NC4yODIsMTEzLjU0OSBMMTE5LjA4NCw5OS4yMjkgQzExOS42Myw5OC45MTQgMTE5LjkzLDk4Ljc0IDEyMC4xMDEsOTguNjU0IEwxMTkuNjM1LDk3LjkxNCBDMTE1Ljg2NCw5NC4xMjcgMTEzLjE2OCw5MC4wMzMgMTExLjYyMiw4NS43NDYgQzExMS4zODIsODUuMDc5IDExMS4zODYsODQuNDA0IDExMS42MzMsODMuNzM4IEMxMTIuNDQ4LDgxLjUzOSAxMTUuODM2LDc5Ljk0MyAxMTkuNjg5LDc5Ljk0MyBDMTIwLjI0Niw3OS45NDMgMTIwLjgwNiw3OS45NzYgMTIxLjM1NSw4MC4wNDIgQzEyOC43NjcsODAuOTMzIDEzNS44NDYsODIuNDg3IDE0Mi4zOTYsODQuNjYzIEMxNDMuMjMyLDg0LjgzOCAxNDMuNjExLDg0LjkxNyAxNDMuNzg2LDg0Ljk2NyBMMTY5LjU2Niw3MC4wODMgTDE0NS42ODUsNTYuMjk1IEw3MC40MDEsOTkuNzYxIEw3MC40MDEsOTkuNzYxIFoiIGlkPSJGaWxsLTEyIiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2Ny4yMywxOC45NzkgTDE2Ny4yMyw2OS44NSBMMTM5LjkwOSw4NS42MjMgTDEzMy40NDgsNzEuNDU2IEMxMzIuNTM4LDY5LjQ2IDEzMC4wMiw2OS43MTggMTI3LjgyNCw3Mi4wMyBDMTI2Ljc2OSw3My4xNCAxMjUuOTMxLDc0LjU4NSAxMjUuNDk0LDc2LjA0OCBMMTE5LjAzNCw5Ny42NzYgTDkxLjcxMiwxMTMuNDUgTDkxLjcxMiw2Mi41NzkgTDE2Ny4yMywxOC45NzkiIGlkPSJGaWxsLTEzIiBmaWxsPSIjRkZGRkZGIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTkxLjcxMiwxMTMuNTY3IEM5MS42OTIsMTEzLjU2NyA5MS42NzIsMTEzLjU2MSA5MS42NTMsMTEzLjU1MSBDOTEuNjE4LDExMy41MyA5MS41OTUsMTEzLjQ5MiA5MS41OTUsMTEzLjQ1IEw5MS41OTUsNjIuNTc5IEM5MS41OTUsNjIuNTM3IDkxLjYxOCw2Mi40OTkgOTEuNjUzLDYyLjQ3OCBMMTY3LjE3MiwxOC44NzggQzE2Ny4yMDgsMTguODU3IDE2Ny4yNTIsMTguODU3IDE2Ny4yODgsMTguODc4IEMxNjcuMzI0LDE4Ljg5OSAxNjcuMzQ3LDE4LjkzNyAxNjcuMzQ3LDE4Ljk3OSBMMTY3LjM0Nyw2OS44NSBDMTY3LjM0Nyw2OS44OTEgMTY3LjMyNCw2OS45MyAxNjcuMjg4LDY5Ljk1IEwxMzkuOTY3LDg1LjcyNSBDMTM5LjkzOSw4NS43NDEgMTM5LjkwNSw4NS43NDUgMTM5Ljg3Myw4NS43MzUgQzEzOS44NDIsODUuNzI1IDEzOS44MTYsODUuNzAyIDEzOS44MDIsODUuNjcyIEwxMzMuMzQyLDcxLjUwNCBDMTMyLjk2Nyw3MC42ODIgMTMyLjI4LDcwLjIyOSAxMzEuNDA4LDcwLjIyOSBDMTMwLjMxOSw3MC4yMjkgMTI5LjA0NCw3MC45MTUgMTI3LjkwOCw3Mi4xMSBDMTI2Ljg3NCw3My4yIDEyNi4wMzQsNzQuNjQ3IDEyNS42MDYsNzYuMDgyIEwxMTkuMTQ2LDk3LjcwOSBDMTE5LjEzNyw5Ny43MzggMTE5LjExOCw5Ny43NjIgMTE5LjA5Miw5Ny43NzcgTDkxLjc3LDExMy41NTEgQzkxLjc1MiwxMTMuNTYxIDkxLjczMiwxMTMuNTY3IDkxLjcxMiwxMTMuNTY3IEw5MS43MTIsMTEzLjU2NyBaIE05MS44MjksNjIuNjQ3IEw5MS44MjksMTEzLjI0OCBMMTE4LjkzNSw5Ny41OTggTDEyNS4zODIsNzYuMDE1IEMxMjUuODI3LDc0LjUyNSAxMjYuNjY0LDczLjA4MSAxMjcuNzM5LDcxLjk1IEMxMjguOTE5LDcwLjcwOCAxMzAuMjU2LDY5Ljk5NiAxMzEuNDA4LDY5Ljk5NiBDMTMyLjM3Nyw2OS45OTYgMTMzLjEzOSw3MC40OTcgMTMzLjU1NCw3MS40MDcgTDEzOS45NjEsODUuNDU4IEwxNjcuMTEzLDY5Ljc4MiBMMTY3LjExMywxOS4xODEgTDkxLjgyOSw2Mi42NDcgTDkxLjgyOSw2Mi42NDcgWiIgaWQ9IkZpbGwtMTQiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTY4LjU0MywxOS4yMTMgTDE2OC41NDMsNzAuMDgzIEwxNDEuMjIxLDg1Ljg1NyBMMTM0Ljc2MSw3MS42ODkgQzEzMy44NTEsNjkuNjk0IDEzMS4zMzMsNjkuOTUxIDEyOS4xMzcsNzIuMjYzIEMxMjguMDgyLDczLjM3NCAxMjcuMjQ0LDc0LjgxOSAxMjYuODA3LDc2LjI4MiBMMTIwLjM0Niw5Ny45MDkgTDkzLjAyNSwxMTMuNjgzIEw5My4wMjUsNjIuODEzIEwxNjguNTQzLDE5LjIxMyIgaWQ9IkZpbGwtMTUiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNOTMuMDI1LDExMy44IEM5My4wMDUsMTEzLjggOTIuOTg0LDExMy43OTUgOTIuOTY2LDExMy43ODUgQzkyLjkzMSwxMTMuNzY0IDkyLjkwOCwxMTMuNzI1IDkyLjkwOCwxMTMuNjg0IEw5Mi45MDgsNjIuODEzIEM5Mi45MDgsNjIuNzcxIDkyLjkzMSw2Mi43MzMgOTIuOTY2LDYyLjcxMiBMMTY4LjQ4NCwxOS4xMTIgQzE2OC41MiwxOS4wOSAxNjguNTY1LDE5LjA5IDE2OC42MDEsMTkuMTEyIEMxNjguNjM3LDE5LjEzMiAxNjguNjYsMTkuMTcxIDE2OC42NiwxOS4yMTIgTDE2OC42Niw3MC4wODMgQzE2OC42Niw3MC4xMjUgMTY4LjYzNyw3MC4xNjQgMTY4LjYwMSw3MC4xODQgTDE0MS4yOCw4NS45NTggQzE0MS4yNTEsODUuOTc1IDE0MS4yMTcsODUuOTc5IDE0MS4xODYsODUuOTY4IEMxNDEuMTU0LDg1Ljk1OCAxNDEuMTI5LDg1LjkzNiAxNDEuMTE1LDg1LjkwNiBMMTM0LjY1NSw3MS43MzggQzEzNC4yOCw3MC45MTUgMTMzLjU5Myw3MC40NjMgMTMyLjcyLDcwLjQ2MyBDMTMxLjYzMiw3MC40NjMgMTMwLjM1Nyw3MS4xNDggMTI5LjIyMSw3Mi4zNDQgQzEyOC4xODYsNzMuNDMzIDEyNy4zNDcsNzQuODgxIDEyNi45MTksNzYuMzE1IEwxMjAuNDU4LDk3Ljk0MyBDMTIwLjQ1LDk3Ljk3MiAxMjAuNDMxLDk3Ljk5NiAxMjAuNDA1LDk4LjAxIEw5My4wODMsMTEzLjc4NSBDOTMuMDY1LDExMy43OTUgOTMuMDQ1LDExMy44IDkzLjAyNSwxMTMuOCBMOTMuMDI1LDExMy44IFogTTkzLjE0Miw2Mi44ODEgTDkzLjE0MiwxMTMuNDgxIEwxMjAuMjQ4LDk3LjgzMiBMMTI2LjY5NSw3Ni4yNDggQzEyNy4xNCw3NC43NTggMTI3Ljk3Nyw3My4zMTUgMTI5LjA1Miw3Mi4xODMgQzEzMC4yMzEsNzAuOTQyIDEzMS41NjgsNzAuMjI5IDEzMi43Miw3MC4yMjkgQzEzMy42ODksNzAuMjI5IDEzNC40NTIsNzAuNzMxIDEzNC44NjcsNzEuNjQxIEwxNDEuMjc0LDg1LjY5MiBMMTY4LjQyNiw3MC4wMTYgTDE2OC40MjYsMTkuNDE1IEw5My4xNDIsNjIuODgxIEw5My4xNDIsNjIuODgxIFoiIGlkPSJGaWxsLTE2IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2OS44LDcwLjA4MyBMMTQyLjQ3OCw4NS44NTcgTDEzNi4wMTgsNzEuNjg5IEMxMzUuMTA4LDY5LjY5NCAxMzIuNTksNjkuOTUxIDEzMC4zOTMsNzIuMjYzIEMxMjkuMzM5LDczLjM3NCAxMjguNSw3NC44MTkgMTI4LjA2NCw3Ni4yODIgTDEyMS42MDMsOTcuOTA5IEw5NC4yODIsMTEzLjY4MyBMOTQuMjgyLDYyLjgxMyBMMTY5LjgsMTkuMjEzIEwxNjkuOCw3MC4wODMgWiIgaWQ9IkZpbGwtMTciIGZpbGw9IiNGQUZBRkEiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNOTQuMjgyLDExMy45MTcgQzk0LjI0MSwxMTMuOTE3IDk0LjIwMSwxMTMuOTA3IDk0LjE2NSwxMTMuODg2IEM5NC4wOTMsMTEzLjg0NSA5NC4wNDgsMTEzLjc2NyA5NC4wNDgsMTEzLjY4NCBMOTQuMDQ4LDYyLjgxMyBDOTQuMDQ4LDYyLjczIDk0LjA5Myw2Mi42NTIgOTQuMTY1LDYyLjYxMSBMMTY5LjY4MywxOS4wMSBDMTY5Ljc1NSwxOC45NjkgMTY5Ljg0NCwxOC45NjkgMTY5LjkxNywxOS4wMSBDMTY5Ljk4OSwxOS4wNTIgMTcwLjAzMywxOS4xMjkgMTcwLjAzMywxOS4yMTIgTDE3MC4wMzMsNzAuMDgzIEMxNzAuMDMzLDcwLjE2NiAxNjkuOTg5LDcwLjI0NCAxNjkuOTE3LDcwLjI4NSBMMTQyLjU5NSw4Ni4wNiBDMTQyLjUzOCw4Ni4wOTIgMTQyLjQ2OSw4Ni4xIDE0Mi40MDcsODYuMDggQzE0Mi4zNDQsODYuMDYgMTQyLjI5Myw4Ni4wMTQgMTQyLjI2Niw4NS45NTQgTDEzNS44MDUsNzEuNzg2IEMxMzUuNDQ1LDcwLjk5NyAxMzQuODEzLDcwLjU4IDEzMy45NzcsNzAuNTggQzEzMi45MjEsNzAuNTggMTMxLjY3Niw3MS4yNTIgMTMwLjU2Miw3Mi40MjQgQzEyOS41NCw3My41MDEgMTI4LjcxMSw3NC45MzEgMTI4LjI4Nyw3Ni4zNDggTDEyMS44MjcsOTcuOTc2IEMxMjEuODEsOTguMDM0IDEyMS43NzEsOTguMDgyIDEyMS43Miw5OC4xMTIgTDk0LjM5OCwxMTMuODg2IEM5NC4zNjIsMTEzLjkwNyA5NC4zMjIsMTEzLjkxNyA5NC4yODIsMTEzLjkxNyBMOTQuMjgyLDExMy45MTcgWiBNOTQuNTE1LDYyLjk0OCBMOTQuNTE1LDExMy4yNzkgTDEyMS40MDYsOTcuNzU0IEwxMjcuODQsNzYuMjE1IEMxMjguMjksNzQuNzA4IDEyOS4xMzcsNzMuMjQ3IDEzMC4yMjQsNzIuMTAzIEMxMzEuNDI1LDcwLjgzOCAxMzIuNzkzLDcwLjExMiAxMzMuOTc3LDcwLjExMiBDMTM0Ljk5NSw3MC4xMTIgMTM1Ljc5NSw3MC42MzggMTM2LjIzLDcxLjU5MiBMMTQyLjU4NCw4NS41MjYgTDE2OS41NjYsNjkuOTQ4IEwxNjkuNTY2LDE5LjYxNyBMOTQuNTE1LDYyLjk0OCBMOTQuNTE1LDYyLjk0OCBaIiBpZD0iRmlsbC0xOCIgZmlsbD0iIzYwN0Q4QiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMDkuODk0LDkyLjk0MyBMMTA5Ljg5NCw5Mi45NDMgQzEwOC4xMiw5Mi45NDMgMTA2LjY1Myw5Mi4yMTggMTA1LjY1LDkwLjgyMyBDMTA1LjU4Myw5MC43MzEgMTA1LjU5Myw5MC42MSAxMDUuNjczLDkwLjUyOSBDMTA1Ljc1Myw5MC40NDggMTA1Ljg4LDkwLjQ0IDEwNS45NzQsOTAuNTA2IEMxMDYuNzU0LDkxLjA1MyAxMDcuNjc5LDkxLjMzMyAxMDguNzI0LDkxLjMzMyBDMTEwLjA0Nyw5MS4zMzMgMTExLjQ3OCw5MC44OTQgMTEyLjk4LDkwLjAyNyBDMTE4LjI5MSw4Ni45NiAxMjIuNjExLDc5LjUwOSAxMjIuNjExLDczLjQxNiBDMTIyLjYxMSw3MS40ODkgMTIyLjE2OSw2OS44NTYgMTIxLjMzMyw2OC42OTIgQzEyMS4yNjYsNjguNiAxMjEuMjc2LDY4LjQ3MyAxMjEuMzU2LDY4LjM5MiBDMTIxLjQzNiw2OC4zMTEgMTIxLjU2Myw2OC4yOTkgMTIxLjY1Niw2OC4zNjUgQzEyMy4zMjcsNjkuNTM3IDEyNC4yNDcsNzEuNzQ2IDEyNC4yNDcsNzQuNTg0IEMxMjQuMjQ3LDgwLjgyNiAxMTkuODIxLDg4LjQ0NyAxMTQuMzgyLDkxLjU4NyBDMTEyLjgwOCw5Mi40OTUgMTExLjI5OCw5Mi45NDMgMTA5Ljg5NCw5Mi45NDMgTDEwOS44OTQsOTIuOTQzIFogTTEwNi45MjUsOTEuNDAxIEMxMDcuNzM4LDkyLjA1MiAxMDguNzQ1LDkyLjI3OCAxMDkuODkzLDkyLjI3OCBMMTA5Ljg5NCw5Mi4yNzggQzExMS4yMTUsOTIuMjc4IDExMi42NDcsOTEuOTUxIDExNC4xNDgsOTEuMDg0IEMxMTkuNDU5LDg4LjAxNyAxMjMuNzgsODAuNjIxIDEyMy43OCw3NC41MjggQzEyMy43OCw3Mi41NDkgMTIzLjMxNyw3MC45MjkgMTIyLjQ1NCw2OS43NjcgQzEyMi44NjUsNzAuODAyIDEyMy4wNzksNzIuMDQyIDEyMy4wNzksNzMuNDAyIEMxMjMuMDc5LDc5LjY0NSAxMTguNjUzLDg3LjI4NSAxMTMuMjE0LDkwLjQyNSBDMTExLjY0LDkxLjMzNCAxMTAuMTMsOTEuNzQyIDEwOC43MjQsOTEuNzQyIEMxMDguMDgzLDkxLjc0MiAxMDcuNDgxLDkxLjU5MyAxMDYuOTI1LDkxLjQwMSBMMTA2LjkyNSw5MS40MDEgWiIgaWQ9IkZpbGwtMTkiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTEzLjA5Nyw5MC4yMyBDMTE4LjQ4MSw4Ny4xMjIgMTIyLjg0NSw3OS41OTQgMTIyLjg0NSw3My40MTYgQzEyMi44NDUsNzEuMzY1IDEyMi4zNjIsNjkuNzI0IDEyMS41MjIsNjguNTU2IEMxMTkuNzM4LDY3LjMwNCAxMTcuMTQ4LDY3LjM2MiAxMTQuMjY1LDY5LjAyNiBDMTA4Ljg4MSw3Mi4xMzQgMTA0LjUxNyw3OS42NjIgMTA0LjUxNyw4NS44NCBDMTA0LjUxNyw4Ny44OTEgMTA1LDg5LjUzMiAxMDUuODQsOTAuNyBDMTA3LjYyNCw5MS45NTIgMTEwLjIxNCw5MS44OTQgMTEzLjA5Nyw5MC4yMyIgaWQ9IkZpbGwtMjAiIGZpbGw9IiNGQUZBRkEiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTA4LjcyNCw5MS42MTQgTDEwOC43MjQsOTEuNjE0IEMxMDcuNTgyLDkxLjYxNCAxMDYuNTY2LDkxLjQwMSAxMDUuNzA1LDkwLjc5NyBDMTA1LjY4NCw5MC43ODMgMTA1LjY2NSw5MC44MTEgMTA1LjY1LDkwLjc5IEMxMDQuNzU2LDg5LjU0NiAxMDQuMjgzLDg3Ljg0MiAxMDQuMjgzLDg1LjgxNyBDMTA0LjI4Myw3OS41NzUgMTA4LjcwOSw3MS45NTMgMTE0LjE0OCw2OC44MTIgQzExNS43MjIsNjcuOTA0IDExNy4yMzIsNjcuNDQ5IDExOC42MzgsNjcuNDQ5IEMxMTkuNzgsNjcuNDQ5IDEyMC43OTYsNjcuNzU4IDEyMS42NTYsNjguMzYyIEMxMjEuNjc4LDY4LjM3NyAxMjEuNjk3LDY4LjM5NyAxMjEuNzEyLDY4LjQxOCBDMTIyLjYwNiw2OS42NjIgMTIzLjA3OSw3MS4zOSAxMjMuMDc5LDczLjQxNSBDMTIzLjA3OSw3OS42NTggMTE4LjY1Myw4Ny4xOTggMTEzLjIxNCw5MC4zMzggQzExMS42NCw5MS4yNDcgMTEwLjEzLDkxLjYxNCAxMDguNzI0LDkxLjYxNCBMMTA4LjcyNCw5MS42MTQgWiBNMTA2LjAwNiw5MC41MDUgQzEwNi43OCw5MS4wMzcgMTA3LjY5NCw5MS4yODEgMTA4LjcyNCw5MS4yODEgQzExMC4wNDcsOTEuMjgxIDExMS40NzgsOTAuODY4IDExMi45OCw5MC4wMDEgQzExOC4yOTEsODYuOTM1IDEyMi42MTEsNzkuNDk2IDEyMi42MTEsNzMuNDAzIEMxMjIuNjExLDcxLjQ5NCAxMjIuMTc3LDY5Ljg4IDEyMS4zNTYsNjguNzE4IEMxMjAuNTgyLDY4LjE4NSAxMTkuNjY4LDY3LjkxOSAxMTguNjM4LDY3LjkxOSBDMTE3LjMxNSw2Ny45MTkgMTE1Ljg4Myw2OC4zNiAxMTQuMzgyLDY5LjIyNyBDMTA5LjA3MSw3Mi4yOTMgMTA0Ljc1MSw3OS43MzMgMTA0Ljc1MSw4NS44MjYgQzEwNC43NTEsODcuNzM1IDEwNS4xODUsODkuMzQzIDEwNi4wMDYsOTAuNTA1IEwxMDYuMDA2LDkwLjUwNSBaIiBpZD0iRmlsbC0yMSIgZmlsbD0iIzYwN0Q4QiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNDkuMzE4LDcuMjYyIEwxMzkuMzM0LDE2LjE0IEwxNTUuMjI3LDI3LjE3MSBMMTYwLjgxNiwyMS4wNTkgTDE0OS4zMTgsNy4yNjIiIGlkPSJGaWxsLTIyIiBmaWxsPSIjRkFGQUZBIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2OS42NzYsMTMuODQgTDE1OS45MjgsMTkuNDY3IEMxNTYuMjg2LDIxLjU3IDE1MC40LDIxLjU4IDE0Ni43ODEsMTkuNDkxIEMxNDMuMTYxLDE3LjQwMiAxNDMuMTgsMTQuMDAzIDE0Ni44MjIsMTEuOSBMMTU2LjMxNyw2LjI5MiBMMTQ5LjU4OCwyLjQwNyBMNjcuNzUyLDQ5LjQ3OCBMMTEzLjY3NSw3NS45OTIgTDExNi43NTYsNzQuMjEzIEMxMTcuMzg3LDczLjg0OCAxMTcuNjI1LDczLjMxNSAxMTcuMzc0LDcyLjgyMyBDMTE1LjAxNyw2OC4xOTEgMTE0Ljc4MSw2My4yNzcgMTE2LjY5MSw1OC41NjEgQzEyMi4zMjksNDQuNjQxIDE0MS4yLDMzLjc0NiAxNjUuMzA5LDMwLjQ5MSBDMTczLjQ3OCwyOS4zODggMTgxLjk4OSwyOS41MjQgMTkwLjAxMywzMC44ODUgQzE5MC44NjUsMzEuMDMgMTkxLjc4OSwzMC44OTMgMTkyLjQyLDMwLjUyOCBMMTk1LjUwMSwyOC43NSBMMTY5LjY3NiwxMy44NCIgaWQ9IkZpbGwtMjMiIGZpbGw9IiNGQUZBRkEiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTEzLjY3NSw3Ni40NTkgQzExMy41OTQsNzYuNDU5IDExMy41MTQsNzYuNDM4IDExMy40NDIsNzYuMzk3IEw2Ny41MTgsNDkuODgyIEM2Ny4zNzQsNDkuNzk5IDY3LjI4NCw0OS42NDUgNjcuMjg1LDQ5LjQ3OCBDNjcuMjg1LDQ5LjMxMSA2Ny4zNzQsNDkuMTU3IDY3LjUxOSw0OS4wNzMgTDE0OS4zNTUsMi4wMDIgQzE0OS40OTksMS45MTkgMTQ5LjY3NywxLjkxOSAxNDkuODIxLDIuMDAyIEwxNTYuNTUsNS44ODcgQzE1Ni43NzQsNi4wMTcgMTU2Ljg1LDYuMzAyIDE1Ni43MjIsNi41MjYgQzE1Ni41OTIsNi43NDkgMTU2LjMwNyw2LjgyNiAxNTYuMDgzLDYuNjk2IEwxNDkuNTg3LDIuOTQ2IEw2OC42ODcsNDkuNDc5IEwxMTMuNjc1LDc1LjQ1MiBMMTE2LjUyMyw3My44MDggQzExNi43MTUsNzMuNjk3IDExNy4xNDMsNzMuMzk5IDExNi45NTgsNzMuMDM1IEMxMTQuNTQyLDY4LjI4NyAxMTQuMyw2My4yMjEgMTE2LjI1OCw1OC4zODUgQzExOS4wNjQsNTEuNDU4IDEyNS4xNDMsNDUuMTQzIDEzMy44NCw0MC4xMjIgQzE0Mi40OTcsMzUuMTI0IDE1My4zNTgsMzEuNjMzIDE2NS4yNDcsMzAuMDI4IEMxNzMuNDQ1LDI4LjkyMSAxODIuMDM3LDI5LjA1OCAxOTAuMDkxLDMwLjQyNSBDMTkwLjgzLDMwLjU1IDE5MS42NTIsMzAuNDMyIDE5Mi4xODYsMzAuMTI0IEwxOTQuNTY3LDI4Ljc1IEwxNjkuNDQyLDE0LjI0NCBDMTY5LjIxOSwxNC4xMTUgMTY5LjE0MiwxMy44MjkgMTY5LjI3MSwxMy42MDYgQzE2OS40LDEzLjM4MiAxNjkuNjg1LDEzLjMwNiAxNjkuOTA5LDEzLjQzNSBMMTk1LjczNCwyOC4zNDUgQzE5NS44NzksMjguNDI4IDE5NS45NjgsMjguNTgzIDE5NS45NjgsMjguNzUgQzE5NS45NjgsMjguOTE2IDE5NS44NzksMjkuMDcxIDE5NS43MzQsMjkuMTU0IEwxOTIuNjUzLDMwLjkzMyBDMTkxLjkzMiwzMS4zNSAxOTAuODksMzEuNTA4IDE4OS45MzUsMzEuMzQ2IEMxODEuOTcyLDI5Ljk5NSAxNzMuNDc4LDI5Ljg2IDE2NS4zNzIsMzAuOTU0IEMxNTMuNjAyLDMyLjU0MyAxNDIuODYsMzUuOTkzIDEzNC4zMDcsNDAuOTMxIEMxMjUuNzkzLDQ1Ljg0NyAxMTkuODUxLDUyLjAwNCAxMTcuMTI0LDU4LjczNiBDMTE1LjI3LDYzLjMxNCAxMTUuNTAxLDY4LjExMiAxMTcuNzksNzIuNjExIEMxMTguMTYsNzMuMzM2IDExNy44NDUsNzQuMTI0IDExNi45OSw3NC42MTcgTDExMy45MDksNzYuMzk3IEMxMTMuODM2LDc2LjQzOCAxMTMuNzU2LDc2LjQ1OSAxMTMuNjc1LDc2LjQ1OSIgaWQ9IkZpbGwtMjQiIGZpbGw9IiM0NTVBNjQiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTUzLjMxNiwyMS4yNzkgQzE1MC45MDMsMjEuMjc5IDE0OC40OTUsMjAuNzUxIDE0Ni42NjQsMTkuNjkzIEMxNDQuODQ2LDE4LjY0NCAxNDMuODQ0LDE3LjIzMiAxNDMuODQ0LDE1LjcxOCBDMTQzLjg0NCwxNC4xOTEgMTQ0Ljg2LDEyLjc2MyAxNDYuNzA1LDExLjY5OCBMMTU2LjE5OCw2LjA5MSBDMTU2LjMwOSw2LjAyNSAxNTYuNDUyLDYuMDYyIDE1Ni41MTgsNi4xNzMgQzE1Ni41ODMsNi4yODQgMTU2LjU0Nyw2LjQyNyAxNTYuNDM2LDYuNDkzIEwxNDYuOTQsMTIuMTAyIEMxNDUuMjQ0LDEzLjA4MSAxNDQuMzEyLDE0LjM2NSAxNDQuMzEyLDE1LjcxOCBDMTQ0LjMxMiwxNy4wNTggMTQ1LjIzLDE4LjMyNiAxNDYuODk3LDE5LjI4OSBDMTUwLjQ0NiwyMS4zMzggMTU2LjI0LDIxLjMyNyAxNTkuODExLDE5LjI2NSBMMTY5LjU1OSwxMy42MzcgQzE2OS42NywxMy41NzMgMTY5LjgxMywxMy42MTEgMTY5Ljg3OCwxMy43MjMgQzE2OS45NDMsMTMuODM0IDE2OS45MDQsMTMuOTc3IDE2OS43OTMsMTQuMDQyIEwxNjAuMDQ1LDE5LjY3IEMxNTguMTg3LDIwLjc0MiAxNTUuNzQ5LDIxLjI3OSAxNTMuMzE2LDIxLjI3OSIgaWQ9IkZpbGwtMjUiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTEzLjY3NSw3NS45OTIgTDY3Ljc2Miw0OS40ODQiIGlkPSJGaWxsLTI2IiBmaWxsPSIjNDU1QTY0Ij48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTExMy42NzUsNzYuMzQyIEMxMTMuNjE1LDc2LjM0MiAxMTMuNTU1LDc2LjMyNyAxMTMuNSw3Ni4yOTUgTDY3LjU4Nyw0OS43ODcgQzY3LjQxOSw0OS42OSA2Ny4zNjIsNDkuNDc2IDY3LjQ1OSw0OS4zMDkgQzY3LjU1Niw0OS4xNDEgNjcuNzcsNDkuMDgzIDY3LjkzNyw0OS4xOCBMMTEzLjg1LDc1LjY4OCBDMTE0LjAxOCw3NS43ODUgMTE0LjA3NSw3NiAxMTMuOTc4LDc2LjE2NyBDMTEzLjkxNCw3Ni4yNzkgMTEzLjc5Niw3Ni4zNDIgMTEzLjY3NSw3Ni4zNDIiIGlkPSJGaWxsLTI3IiBmaWxsPSIjNDU1QTY0Ij48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTY3Ljc2Miw0OS40ODQgTDY3Ljc2MiwxMDMuNDg1IEM2Ny43NjIsMTA0LjU3NSA2OC41MzIsMTA1LjkwMyA2OS40ODIsMTA2LjQ1MiBMMTExLjk1NSwxMzAuOTczIEMxMTIuOTA1LDEzMS41MjIgMTEzLjY3NSwxMzEuMDgzIDExMy42NzUsMTI5Ljk5MyBMMTEzLjY3NSw3NS45OTIiIGlkPSJGaWxsLTI4IiBmaWxsPSIjRkFGQUZBIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTExMi43MjcsMTMxLjU2MSBDMTEyLjQzLDEzMS41NjEgMTEyLjEwNywxMzEuNDY2IDExMS43OCwxMzEuMjc2IEw2OS4zMDcsMTA2Ljc1NSBDNjguMjQ0LDEwNi4xNDIgNjcuNDEyLDEwNC43MDUgNjcuNDEyLDEwMy40ODUgTDY3LjQxMiw0OS40ODQgQzY3LjQxMiw0OS4yOSA2Ny41NjksNDkuMTM0IDY3Ljc2Miw0OS4xMzQgQzY3Ljk1Niw0OS4xMzQgNjguMTEzLDQ5LjI5IDY4LjExMyw0OS40ODQgTDY4LjExMywxMDMuNDg1IEM2OC4xMTMsMTA0LjQ0NSA2OC44MiwxMDUuNjY1IDY5LjY1NywxMDYuMTQ4IEwxMTIuMTMsMTMwLjY3IEMxMTIuNDc0LDEzMC44NjggMTEyLjc5MSwxMzAuOTEzIDExMywxMzAuNzkyIEMxMTMuMjA2LDEzMC42NzMgMTEzLjMyNSwxMzAuMzgxIDExMy4zMjUsMTI5Ljk5MyBMMTEzLjMyNSw3NS45OTIgQzExMy4zMjUsNzUuNzk4IDExMy40ODIsNzUuNjQxIDExMy42NzUsNzUuNjQxIEMxMTMuODY5LDc1LjY0MSAxMTQuMDI1LDc1Ljc5OCAxMTQuMDI1LDc1Ljk5MiBMMTE0LjAyNSwxMjkuOTkzIEMxMTQuMDI1LDEzMC42NDggMTEzLjc4NiwxMzEuMTQ3IDExMy4zNSwxMzEuMzk5IEMxMTMuMTYyLDEzMS41MDcgMTEyLjk1MiwxMzEuNTYxIDExMi43MjcsMTMxLjU2MSIgaWQ9IkZpbGwtMjkiIGZpbGw9IiM0NTVBNjQiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTEyLjg2LDQwLjUxMiBDMTEyLjg2LDQwLjUxMiAxMTIuODYsNDAuNTEyIDExMi44NTksNDAuNTEyIEMxMTAuNTQxLDQwLjUxMiAxMDguMzYsMzkuOTkgMTA2LjcxNywzOS4wNDEgQzEwNS4wMTIsMzguMDU3IDEwNC4wNzQsMzYuNzI2IDEwNC4wNzQsMzUuMjkyIEMxMDQuMDc0LDMzLjg0NyAxMDUuMDI2LDMyLjUwMSAxMDYuNzU0LDMxLjUwNCBMMTE4Ljc5NSwyNC41NTEgQzEyMC40NjMsMjMuNTg5IDEyMi42NjksMjMuMDU4IDEyNS4wMDcsMjMuMDU4IEMxMjcuMzI1LDIzLjA1OCAxMjkuNTA2LDIzLjU4MSAxMzEuMTUsMjQuNTMgQzEzMi44NTQsMjUuNTE0IDEzMy43OTMsMjYuODQ1IDEzMy43OTMsMjguMjc4IEMxMzMuNzkzLDI5LjcyNCAxMzIuODQxLDMxLjA2OSAxMzEuMTEzLDMyLjA2NyBMMTE5LjA3MSwzOS4wMTkgQzExNy40MDMsMzkuOTgyIDExNS4xOTcsNDAuNTEyIDExMi44Niw0MC41MTIgTDExMi44Niw0MC41MTIgWiBNMTI1LjAwNywyMy43NTkgQzEyMi43OSwyMy43NTkgMTIwLjcwOSwyNC4yNTYgMTE5LjE0NiwyNS4xNTggTDEwNy4xMDQsMzIuMTEgQzEwNS42MDIsMzIuOTc4IDEwNC43NzQsMzQuMTA4IDEwNC43NzQsMzUuMjkyIEMxMDQuNzc0LDM2LjQ2NSAxMDUuNTg5LDM3LjU4MSAxMDcuMDY3LDM4LjQzNCBDMTA4LjYwNSwzOS4zMjMgMTEwLjY2MywzOS44MTIgMTEyLjg1OSwzOS44MTIgTDExMi44NiwzOS44MTIgQzExNS4wNzYsMzkuODEyIDExNy4xNTgsMzkuMzE1IDExOC43MjEsMzguNDEzIEwxMzAuNzYyLDMxLjQ2IEMxMzIuMjY0LDMwLjU5MyAxMzMuMDkyLDI5LjQ2MyAxMzMuMDkyLDI4LjI3OCBDMTMzLjA5MiwyNy4xMDYgMTMyLjI3OCwyNS45OSAxMzAuOCwyNS4xMzYgQzEyOS4yNjEsMjQuMjQ4IDEyNy4yMDQsMjMuNzU5IDEyNS4wMDcsMjMuNzU5IEwxMjUuMDA3LDIzLjc1OSBaIiBpZD0iRmlsbC0zMCIgZmlsbD0iIzYwN0Q4QiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNjUuNjMsMTYuMjE5IEwxNTkuODk2LDE5LjUzIEMxNTYuNzI5LDIxLjM1OCAxNTEuNjEsMjEuMzY3IDE0OC40NjMsMTkuNTUgQzE0NS4zMTYsMTcuNzMzIDE0NS4zMzIsMTQuNzc4IDE0OC40OTksMTIuOTQ5IEwxNTQuMjMzLDkuNjM5IEwxNjUuNjMsMTYuMjE5IiBpZD0iRmlsbC0zMSIgZmlsbD0iI0ZBRkFGQSI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNTQuMjMzLDEwLjQ0OCBMMTY0LjIyOCwxNi4yMTkgTDE1OS41NDYsMTguOTIzIEMxNTguMTEyLDE5Ljc1IDE1Ni4xOTQsMjAuMjA2IDE1NC4xNDcsMjAuMjA2IEMxNTIuMTE4LDIwLjIwNiAxNTAuMjI0LDE5Ljc1NyAxNDguODE0LDE4Ljk0MyBDMTQ3LjUyNCwxOC4xOTkgMTQ2LjgxNCwxNy4yNDkgMTQ2LjgxNCwxNi4yNjkgQzE0Ni44MTQsMTUuMjc4IDE0Ny41MzcsMTQuMzE0IDE0OC44NSwxMy41NTYgTDE1NC4yMzMsMTAuNDQ4IE0xNTQuMjMzLDkuNjM5IEwxNDguNDk5LDEyLjk0OSBDMTQ1LjMzMiwxNC43NzggMTQ1LjMxNiwxNy43MzMgMTQ4LjQ2MywxOS41NSBDMTUwLjAzMSwyMC40NTUgMTUyLjA4NiwyMC45MDcgMTU0LjE0NywyMC45MDcgQzE1Ni4yMjQsMjAuOTA3IDE1OC4zMDYsMjAuNDQ3IDE1OS44OTYsMTkuNTMgTDE2NS42MywxNi4yMTkgTDE1NC4yMzMsOS42MzkiIGlkPSJGaWxsLTMyIiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE0NS40NDUsNzIuNjY3IEwxNDUuNDQ1LDcyLjY2NyBDMTQzLjY3Miw3Mi42NjcgMTQyLjIwNCw3MS44MTcgMTQxLjIwMiw3MC40MjIgQzE0MS4xMzUsNzAuMzMgMTQxLjE0NSw3MC4xNDcgMTQxLjIyNSw3MC4wNjYgQzE0MS4zMDUsNjkuOTg1IDE0MS40MzIsNjkuOTQ2IDE0MS41MjUsNzAuMDExIEMxNDIuMzA2LDcwLjU1OSAxNDMuMjMxLDcwLjgyMyAxNDQuMjc2LDcwLjgyMiBDMTQ1LjU5OCw3MC44MjIgMTQ3LjAzLDcwLjM3NiAxNDguNTMyLDY5LjUwOSBDMTUzLjg0Miw2Ni40NDMgMTU4LjE2Myw1OC45ODcgMTU4LjE2Myw1Mi44OTQgQzE1OC4xNjMsNTAuOTY3IDE1Ny43MjEsNDkuMzMyIDE1Ni44ODQsNDguMTY4IEMxNTYuODE4LDQ4LjA3NiAxNTYuODI4LDQ3Ljk0OCAxNTYuOTA4LDQ3Ljg2NyBDMTU2Ljk4OCw0Ny43ODYgMTU3LjExNCw0Ny43NzQgMTU3LjIwOCw0Ny44NCBDMTU4Ljg3OCw0OS4wMTIgMTU5Ljc5OCw1MS4yMiAxNTkuNzk4LDU0LjA1OSBDMTU5Ljc5OCw2MC4zMDEgMTU1LjM3Myw2OC4wNDYgMTQ5LjkzMyw3MS4xODYgQzE0OC4zNiw3Mi4wOTQgMTQ2Ljg1LDcyLjY2NyAxNDUuNDQ1LDcyLjY2NyBMMTQ1LjQ0NSw3Mi42NjcgWiBNMTQyLjQ3Niw3MSBDMTQzLjI5LDcxLjY1MSAxNDQuMjk2LDcyLjAwMiAxNDUuNDQ1LDcyLjAwMiBDMTQ2Ljc2Nyw3Mi4wMDIgMTQ4LjE5OCw3MS41NSAxNDkuNyw3MC42ODIgQzE1NS4wMSw2Ny42MTcgMTU5LjMzMSw2MC4xNTkgMTU5LjMzMSw1NC4wNjUgQzE1OS4zMzEsNTIuMDg1IDE1OC44NjgsNTAuNDM1IDE1OC4wMDYsNDkuMjcyIEMxNTguNDE3LDUwLjMwNyAxNTguNjMsNTEuNTMyIDE1OC42Myw1Mi44OTIgQzE1OC42Myw1OS4xMzQgMTU0LjIwNSw2Ni43NjcgMTQ4Ljc2NSw2OS45MDcgQzE0Ny4xOTIsNzAuODE2IDE0NS42ODEsNzEuMjgzIDE0NC4yNzYsNzEuMjgzIEMxNDMuNjM0LDcxLjI4MyAxNDMuMDMzLDcxLjE5MiAxNDIuNDc2LDcxIEwxNDIuNDc2LDcxIFoiIGlkPSJGaWxsLTMzIiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE0OC42NDgsNjkuNzA0IEMxNTQuMDMyLDY2LjU5NiAxNTguMzk2LDU5LjA2OCAxNTguMzk2LDUyLjg5MSBDMTU4LjM5Niw1MC44MzkgMTU3LjkxMyw0OS4xOTggMTU3LjA3NCw0OC4wMyBDMTU1LjI4OSw0Ni43NzggMTUyLjY5OSw0Ni44MzYgMTQ5LjgxNiw0OC41MDEgQzE0NC40MzMsNTEuNjA5IDE0MC4wNjgsNTkuMTM3IDE0MC4wNjgsNjUuMzE0IEMxNDAuMDY4LDY3LjM2NSAxNDAuNTUyLDY5LjAwNiAxNDEuMzkxLDcwLjE3NCBDMTQzLjE3Niw3MS40MjcgMTQ1Ljc2NSw3MS4zNjkgMTQ4LjY0OCw2OS43MDQiIGlkPSJGaWxsLTM0IiBmaWxsPSIjRkFGQUZBIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE0NC4yNzYsNzEuMjc2IEwxNDQuMjc2LDcxLjI3NiBDMTQzLjEzMyw3MS4yNzYgMTQyLjExOCw3MC45NjkgMTQxLjI1Nyw3MC4zNjUgQzE0MS4yMzYsNzAuMzUxIDE0MS4yMTcsNzAuMzMyIDE0MS4yMDIsNzAuMzExIEMxNDAuMzA3LDY5LjA2NyAxMzkuODM1LDY3LjMzOSAxMzkuODM1LDY1LjMxNCBDMTM5LjgzNSw1OS4wNzMgMTQ0LjI2LDUxLjQzOSAxNDkuNyw0OC4yOTggQzE1MS4yNzMsNDcuMzkgMTUyLjc4NCw0Ni45MjkgMTU0LjE4OSw0Ni45MjkgQzE1NS4zMzIsNDYuOTI5IDE1Ni4zNDcsNDcuMjM2IDE1Ny4yMDgsNDcuODM5IEMxNTcuMjI5LDQ3Ljg1NCAxNTcuMjQ4LDQ3Ljg3MyAxNTcuMjYzLDQ3Ljg5NCBDMTU4LjE1Nyw0OS4xMzggMTU4LjYzLDUwLjg2NSAxNTguNjMsNTIuODkxIEMxNTguNjMsNTkuMTMyIDE1NC4yMDUsNjYuNzY2IDE0OC43NjUsNjkuOTA3IEMxNDcuMTkyLDcwLjgxNSAxNDUuNjgxLDcxLjI3NiAxNDQuMjc2LDcxLjI3NiBMMTQ0LjI3Niw3MS4yNzYgWiBNMTQxLjU1OCw3MC4xMDQgQzE0Mi4zMzEsNzAuNjM3IDE0My4yNDUsNzEuMDA1IDE0NC4yNzYsNzEuMDA1IEMxNDUuNTk4LDcxLjAwNSAxNDcuMDMsNzAuNDY3IDE0OC41MzIsNjkuNiBDMTUzLjg0Miw2Ni41MzQgMTU4LjE2Myw1OS4wMzMgMTU4LjE2Myw1Mi45MzkgQzE1OC4xNjMsNTEuMDMxIDE1Ny43MjksNDkuMzg1IDE1Ni45MDcsNDguMjIzIEMxNTYuMTMzLDQ3LjY5MSAxNTUuMjE5LDQ3LjQwOSAxNTQuMTg5LDQ3LjQwOSBDMTUyLjg2Nyw0Ny40MDkgMTUxLjQzNSw0Ny44NDIgMTQ5LjkzMyw0OC43MDkgQzE0NC42MjMsNTEuNzc1IDE0MC4zMDIsNTkuMjczIDE0MC4zMDIsNjUuMzY2IEMxNDAuMzAyLDY3LjI3NiAxNDAuNzM2LDY4Ljk0MiAxNDEuNTU4LDcwLjEwNCBMMTQxLjU1OCw3MC4xMDQgWiIgaWQ9IkZpbGwtMzUiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTUwLjcyLDY1LjM2MSBMMTUwLjM1Nyw2NS4wNjYgQzE1MS4xNDcsNjQuMDkyIDE1MS44NjksNjMuMDQgMTUyLjUwNSw2MS45MzggQzE1My4zMTMsNjAuNTM5IDE1My45NzgsNTkuMDY3IDE1NC40ODIsNTcuNTYzIEwxNTQuOTI1LDU3LjcxMiBDMTU0LjQxMiw1OS4yNDUgMTUzLjczMyw2MC43NDUgMTUyLjkxLDYyLjE3MiBDMTUyLjI2Miw2My4yOTUgMTUxLjUyNSw2NC4zNjggMTUwLjcyLDY1LjM2MSIgaWQ9IkZpbGwtMzYiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTE1LjkxNyw4NC41MTQgTDExNS41NTQsODQuMjIgQzExNi4zNDQsODMuMjQ1IDExNy4wNjYsODIuMTk0IDExNy43MDIsODEuMDkyIEMxMTguNTEsNzkuNjkyIDExOS4xNzUsNzguMjIgMTE5LjY3OCw3Ni43MTcgTDEyMC4xMjEsNzYuODY1IEMxMTkuNjA4LDc4LjM5OCAxMTguOTMsNzkuODk5IDExOC4xMDYsODEuMzI2IEMxMTcuNDU4LDgyLjQ0OCAxMTYuNzIyLDgzLjUyMSAxMTUuOTE3LDg0LjUxNCIgaWQ9IkZpbGwtMzciIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTE0LDEzMC40NzYgTDExNCwxMzAuMDA4IEwxMTQsNzYuMDUyIEwxMTQsNzUuNTg0IEwxMTQsNzYuMDUyIEwxMTQsMTMwLjAwOCBMMTE0LDEzMC40NzYiIGlkPSJGaWxsLTM4IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyBpZD0iSW1wb3J0ZWQtTGF5ZXJzLUNvcHkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYyLjAwMDAwMCwgMC4wMDAwMDApIiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTkuODIyLDM3LjQ3NCBDMTkuODM5LDM3LjMzOSAxOS43NDcsMzcuMTk0IDE5LjU1NSwzNy4wODIgQzE5LjIyOCwzNi44OTQgMTguNzI5LDM2Ljg3MiAxOC40NDYsMzcuMDM3IEwxMi40MzQsNDAuNTA4IEMxMi4zMDMsNDAuNTg0IDEyLjI0LDQwLjY4NiAxMi4yNDMsNDAuNzkzIEMxMi4yNDUsNDAuOTI1IDEyLjI0NSw0MS4yNTQgMTIuMjQ1LDQxLjM3MSBMMTIuMjQ1LDQxLjQxNCBMMTIuMjM4LDQxLjU0MiBDOC4xNDgsNDMuODg3IDUuNjQ3LDQ1LjMyMSA1LjY0Nyw0NS4zMjEgQzUuNjQ2LDQ1LjMyMSAzLjU3LDQ2LjM2NyAyLjg2LDUwLjUxMyBDMi44Niw1MC41MTMgMS45NDgsNTcuNDc0IDEuOTYyLDcwLjI1OCBDMS45NzcsODIuODI4IDIuNTY4LDg3LjMyOCAzLjEyOSw5MS42MDkgQzMuMzQ5LDkzLjI5MyA2LjEzLDkzLjczNCA2LjEzLDkzLjczNCBDNi40NjEsOTMuNzc0IDYuODI4LDkzLjcwNyA3LjIxLDkzLjQ4NiBMODIuNDgzLDQ5LjkzNSBDODQuMjkxLDQ4Ljg2NiA4NS4xNSw0Ni4yMTYgODUuNTM5LDQzLjY1MSBDODYuNzUyLDM1LjY2MSA4Ny4yMTQsMTAuNjczIDg1LjI2NCwzLjc3MyBDODUuMDY4LDMuMDggODQuNzU0LDIuNjkgODQuMzk2LDIuNDkxIEw4Mi4zMSwxLjcwMSBDODEuNTgzLDEuNzI5IDgwLjg5NCwyLjE2OCA4MC43NzYsMi4yMzYgQzgwLjYzNiwyLjMxNyA0MS44MDcsMjQuNTg1IDIwLjAzMiwzNy4wNzIgTDE5LjgyMiwzNy40NzQiIGlkPSJGaWxsLTEiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNODIuMzExLDEuNzAxIEw4NC4zOTYsMi40OTEgQzg0Ljc1NCwyLjY5IDg1LjA2OCwzLjA4IDg1LjI2NCwzLjc3MyBDODcuMjEzLDEwLjY3MyA4Ni43NTEsMzUuNjYgODUuNTM5LDQzLjY1MSBDODUuMTQ5LDQ2LjIxNiA4NC4yOSw0OC44NjYgODIuNDgzLDQ5LjkzNSBMNy4yMSw5My40ODYgQzYuODk3LDkzLjY2NyA2LjU5NSw5My43NDQgNi4zMTQsOTMuNzQ0IEw2LjEzMSw5My43MzMgQzYuMTMxLDkzLjczNCAzLjM0OSw5My4yOTMgMy4xMjgsOTEuNjA5IEMyLjU2OCw4Ny4zMjcgMS45NzcsODIuODI4IDEuOTYzLDcwLjI1OCBDMS45NDgsNTcuNDc0IDIuODYsNTAuNTEzIDIuODYsNTAuNTEzIEMzLjU3LDQ2LjM2NyA1LjY0Nyw0NS4zMjEgNS42NDcsNDUuMzIxIEM1LjY0Nyw0NS4zMjEgOC4xNDgsNDMuODg3IDEyLjIzOCw0MS41NDIgTDEyLjI0NSw0MS40MTQgTDEyLjI0NSw0MS4zNzEgQzEyLjI0NSw0MS4yNTQgMTIuMjQ1LDQwLjkyNSAxMi4yNDMsNDAuNzkzIEMxMi4yNCw0MC42ODYgMTIuMzAyLDQwLjU4MyAxMi40MzQsNDAuNTA4IEwxOC40NDYsMzcuMDM2IEMxOC41NzQsMzYuOTYyIDE4Ljc0NiwzNi45MjYgMTguOTI3LDM2LjkyNiBDMTkuMTQ1LDM2LjkyNiAxOS4zNzYsMzYuOTc5IDE5LjU1NCwzNy4wODIgQzE5Ljc0NywzNy4xOTQgMTkuODM5LDM3LjM0IDE5LjgyMiwzNy40NzQgTDIwLjAzMywzNy4wNzIgQzQxLjgwNiwyNC41ODUgODAuNjM2LDIuMzE4IDgwLjc3NywyLjIzNiBDODAuODk0LDIuMTY4IDgxLjU4MywxLjcyOSA4Mi4zMTEsMS43MDEgTTgyLjMxMSwwLjcwNCBMODIuMjcyLDAuNzA1IEM4MS42NTQsMC43MjggODAuOTg5LDAuOTQ5IDgwLjI5OCwxLjM2MSBMODAuMjc3LDEuMzczIEM4MC4xMjksMS40NTggNTkuNzY4LDEzLjEzNSAxOS43NTgsMzYuMDc5IEMxOS41LDM1Ljk4MSAxOS4yMTQsMzUuOTI5IDE4LjkyNywzNS45MjkgQzE4LjU2MiwzNS45MjkgMTguMjIzLDM2LjAxMyAxNy45NDcsMzYuMTczIEwxMS45MzUsMzkuNjQ0IEMxMS40OTMsMzkuODk5IDExLjIzNiw0MC4zMzQgMTEuMjQ2LDQwLjgxIEwxMS4yNDcsNDAuOTYgTDUuMTY3LDQ0LjQ0NyBDNC43OTQsNDQuNjQ2IDIuNjI1LDQ1Ljk3OCAxLjg3Nyw1MC4zNDUgTDEuODcxLDUwLjM4NCBDMS44NjIsNTAuNDU0IDAuOTUxLDU3LjU1NyAwLjk2NSw3MC4yNTkgQzAuOTc5LDgyLjg3OSAxLjU2OCw4Ny4zNzUgMi4xMzcsOTEuNzI0IEwyLjEzOSw5MS43MzkgQzIuNDQ3LDk0LjA5NCA1LjYxNCw5NC42NjIgNS45NzUsOTQuNzE5IEw2LjAwOSw5NC43MjMgQzYuMTEsOTQuNzM2IDYuMjEzLDk0Ljc0MiA2LjMxNCw5NC43NDIgQzYuNzksOTQuNzQyIDcuMjYsOTQuNjEgNy43MSw5NC4zNSBMODIuOTgzLDUwLjc5OCBDODQuNzk0LDQ5LjcyNyA4NS45ODIsNDcuMzc1IDg2LjUyNSw0My44MDEgQzg3LjcxMSwzNS45ODcgODguMjU5LDEwLjcwNSA4Ni4yMjQsMy41MDIgQzg1Ljk3MSwyLjYwOSA4NS41MiwxLjk3NSA4NC44ODEsMS42MiBMODQuNzQ5LDEuNTU4IEw4Mi42NjQsMC43NjkgQzgyLjU1MSwwLjcyNSA4Mi40MzEsMC43MDQgODIuMzExLDAuNzA0IiBpZD0iRmlsbC0yIiBmaWxsPSIjNDU1QTY0Ij48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTY2LjI2NywxMS41NjUgTDY3Ljc2MiwxMS45OTkgTDExLjQyMyw0NC4zMjUiIGlkPSJGaWxsLTMiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTIuMjAyLDkwLjU0NSBDMTIuMDI5LDkwLjU0NSAxMS44NjIsOTAuNDU1IDExLjc2OSw5MC4yOTUgQzExLjYzMiw5MC4wNTcgMTEuNzEzLDg5Ljc1MiAxMS45NTIsODkuNjE0IEwzMC4zODksNzguOTY5IEMzMC42MjgsNzguODMxIDMwLjkzMyw3OC45MTMgMzEuMDcxLDc5LjE1MiBDMzEuMjA4LDc5LjM5IDMxLjEyNyw3OS42OTYgMzAuODg4LDc5LjgzMyBMMTIuNDUxLDkwLjQ3OCBMMTIuMjAyLDkwLjU0NSIgaWQ9IkZpbGwtNCIgZmlsbD0iIzYwN0Q4QiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMy43NjQsNDIuNjU0IEwxMy42NTYsNDIuNTkyIEwxMy43MDIsNDIuNDIxIEwxOC44MzcsMzkuNDU3IEwxOS4wMDcsMzkuNTAyIEwxOC45NjIsMzkuNjczIEwxMy44MjcsNDIuNjM3IEwxMy43NjQsNDIuNjU0IiBpZD0iRmlsbC01IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTguNTIsOTAuMzc1IEw4LjUyLDQ2LjQyMSBMOC41ODMsNDYuMzg1IEw3NS44NCw3LjU1NCBMNzUuODQsNTEuNTA4IEw3NS43NzgsNTEuNTQ0IEw4LjUyLDkwLjM3NSBMOC41Miw5MC4zNzUgWiBNOC43Nyw0Ni41NjQgTDguNzcsODkuOTQ0IEw3NS41OTEsNTEuMzY1IEw3NS41OTEsNy45ODUgTDguNzcsNDYuNTY0IEw4Ljc3LDQ2LjU2NCBaIiBpZD0iRmlsbC02IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTI0Ljk4Niw4My4xODIgQzI0Ljc1Niw4My4zMzEgMjQuMzc0LDgzLjU2NiAyNC4xMzcsODMuNzA1IEwxMi42MzIsOTAuNDA2IEMxMi4zOTUsOTAuNTQ1IDEyLjQyNiw5MC42NTggMTIuNyw5MC42NTggTDEzLjI2NSw5MC42NTggQzEzLjU0LDkwLjY1OCAxMy45NTgsOTAuNTQ1IDE0LjE5NSw5MC40MDYgTDI1LjcsODMuNzA1IEMyNS45MzcsODMuNTY2IDI2LjEyOCw4My40NTIgMjYuMTI1LDgzLjQ0OSBDMjYuMTIyLDgzLjQ0NyAyNi4xMTksODMuMjIgMjYuMTE5LDgyLjk0NiBDMjYuMTE5LDgyLjY3MiAyNS45MzEsODIuNTY5IDI1LjcwMSw4Mi43MTkgTDI0Ljk4Niw4My4xODIiIGlkPSJGaWxsLTciIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTMuMjY2LDkwLjc4MiBMMTIuNyw5MC43ODIgQzEyLjUsOTAuNzgyIDEyLjM4NCw5MC43MjYgMTIuMzU0LDkwLjYxNiBDMTIuMzI0LDkwLjUwNiAxMi4zOTcsOTAuMzk5IDEyLjU2OSw5MC4yOTkgTDI0LjA3NCw4My41OTcgQzI0LjMxLDgzLjQ1OSAyNC42ODksODMuMjI2IDI0LjkxOCw4My4wNzggTDI1LjYzMyw4Mi42MTQgQzI1LjcyMyw4Mi41NTUgMjUuODEzLDgyLjUyNSAyNS44OTksODIuNTI1IEMyNi4wNzEsODIuNTI1IDI2LjI0NCw4Mi42NTUgMjYuMjQ0LDgyLjk0NiBDMjYuMjQ0LDgzLjE2IDI2LjI0NSw4My4zMDkgMjYuMjQ3LDgzLjM4MyBMMjYuMjUzLDgzLjM4NyBMMjYuMjQ5LDgzLjQ1NiBDMjYuMjQ2LDgzLjUzMSAyNi4yNDYsODMuNTMxIDI1Ljc2Myw4My44MTIgTDE0LjI1OCw5MC41MTQgQzE0LDkwLjY2NSAxMy41NjQsOTAuNzgyIDEzLjI2Niw5MC43ODIgTDEzLjI2Niw5MC43ODIgWiBNMTIuNjY2LDkwLjUzMiBMMTIuNyw5MC41MzMgTDEzLjI2Niw5MC41MzMgQzEzLjUxOCw5MC41MzMgMTMuOTE1LDkwLjQyNSAxNC4xMzIsOTAuMjk5IEwyNS42MzcsODMuNTk3IEMyNS44MDUsODMuNDk5IDI1LjkzMSw4My40MjQgMjUuOTk4LDgzLjM4MyBDMjUuOTk0LDgzLjI5OSAyNS45OTQsODMuMTY1IDI1Ljk5NCw4Mi45NDYgTDI1Ljg5OSw4Mi43NzUgTDI1Ljc2OCw4Mi44MjQgTDI1LjA1NCw4My4yODcgQzI0LjgyMiw4My40MzcgMjQuNDM4LDgzLjY3MyAyNC4yLDgzLjgxMiBMMTIuNjk1LDkwLjUxNCBMMTIuNjY2LDkwLjUzMiBMMTIuNjY2LDkwLjUzMiBaIiBpZD0iRmlsbC04IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEzLjI2Niw4OS44NzEgTDEyLjcsODkuODcxIEMxMi41LDg5Ljg3MSAxMi4zODQsODkuODE1IDEyLjM1NCw4OS43MDUgQzEyLjMyNCw4OS41OTUgMTIuMzk3LDg5LjQ4OCAxMi41NjksODkuMzg4IEwyNC4wNzQsODIuNjg2IEMyNC4zMzIsODIuNTM1IDI0Ljc2OCw4Mi40MTggMjUuMDY3LDgyLjQxOCBMMjUuNjMyLDgyLjQxOCBDMjUuODMyLDgyLjQxOCAyNS45NDgsODIuNDc0IDI1Ljk3OCw4Mi41ODQgQzI2LjAwOCw4Mi42OTQgMjUuOTM1LDgyLjgwMSAyNS43NjMsODIuOTAxIEwxNC4yNTgsODkuNjAzIEMxNCw4OS43NTQgMTMuNTY0LDg5Ljg3MSAxMy4yNjYsODkuODcxIEwxMy4yNjYsODkuODcxIFogTTEyLjY2Niw4OS42MjEgTDEyLjcsODkuNjIyIEwxMy4yNjYsODkuNjIyIEMxMy41MTgsODkuNjIyIDEzLjkxNSw4OS41MTUgMTQuMTMyLDg5LjM4OCBMMjUuNjM3LDgyLjY4NiBMMjUuNjY3LDgyLjY2OCBMMjUuNjMyLDgyLjY2NyBMMjUuMDY3LDgyLjY2NyBDMjQuODE1LDgyLjY2NyAyNC40MTgsODIuNzc1IDI0LjIsODIuOTAxIEwxMi42OTUsODkuNjAzIEwxMi42NjYsODkuNjIxIEwxMi42NjYsODkuNjIxIFoiIGlkPSJGaWxsLTkiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTIuMzcsOTAuODAxIEwxMi4zNyw4OS41NTQgTDEyLjM3LDkwLjgwMSIgaWQ9IkZpbGwtMTAiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNi4xMyw5My45MDEgQzUuMzc5LDkzLjgwOCA0LjgxNiw5My4xNjQgNC42OTEsOTIuNTI1IEMzLjg2LDg4LjI4NyAzLjU0LDgzLjc0MyAzLjUyNiw3MS4xNzMgQzMuNTExLDU4LjM4OSA0LjQyMyw1MS40MjggNC40MjMsNTEuNDI4IEM1LjEzNCw0Ny4yODIgNy4yMSw0Ni4yMzYgNy4yMSw0Ni4yMzYgQzcuMjEsNDYuMjM2IDgxLjY2NywzLjI1IDgyLjA2OSwzLjAxNyBDODIuMjkyLDIuODg4IDg0LjU1NiwxLjQzMyA4NS4yNjQsMy45NCBDODcuMjE0LDEwLjg0IDg2Ljc1MiwzNS44MjcgODUuNTM5LDQzLjgxOCBDODUuMTUsNDYuMzgzIDg0LjI5MSw0OS4wMzMgODIuNDgzLDUwLjEwMSBMNy4yMSw5My42NTMgQzYuODI4LDkzLjg3NCA2LjQ2MSw5My45NDEgNi4xMyw5My45MDEgQzYuMTMsOTMuOTAxIDMuMzQ5LDkzLjQ2IDMuMTI5LDkxLjc3NiBDMi41NjgsODcuNDk1IDEuOTc3LDgyLjk5NSAxLjk2Miw3MC40MjUgQzEuOTQ4LDU3LjY0MSAyLjg2LDUwLjY4IDIuODYsNTAuNjggQzMuNTcsNDYuNTM0IDUuNjQ3LDQ1LjQ4OSA1LjY0Nyw0NS40ODkgQzUuNjQ2LDQ1LjQ4OSA4LjA2NSw0NC4wOTIgMTIuMjQ1LDQxLjY3OSBMMTMuMTE2LDQxLjU2IEwxOS43MTUsMzcuNzMgTDE5Ljc2MSwzNy4yNjkgTDYuMTMsOTMuOTAxIiBpZD0iRmlsbC0xMSIgZmlsbD0iI0ZBRkFGQSI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik02LjMxNyw5NC4xNjEgTDYuMTAyLDk0LjE0OCBMNi4xMDEsOTQuMTQ4IEw1Ljg1Nyw5NC4xMDEgQzUuMTM4LDkzLjk0NSAzLjA4NSw5My4zNjUgMi44ODEsOTEuODA5IEMyLjMxMyw4Ny40NjkgMS43MjcsODIuOTk2IDEuNzEzLDcwLjQyNSBDMS42OTksNTcuNzcxIDIuNjA0LDUwLjcxOCAyLjYxMyw1MC42NDggQzMuMzM4LDQ2LjQxNyA1LjQ0NSw0NS4zMSA1LjUzNSw0NS4yNjYgTDEyLjE2Myw0MS40MzkgTDEzLjAzMyw0MS4zMiBMMTkuNDc5LDM3LjU3OCBMMTkuNTEzLDM3LjI0NCBDMTkuNTI2LDM3LjEwNyAxOS42NDcsMzcuMDA4IDE5Ljc4NiwzNy4wMjEgQzE5LjkyMiwzNy4wMzQgMjAuMDIzLDM3LjE1NiAyMC4wMDksMzcuMjkzIEwxOS45NSwzNy44ODIgTDEzLjE5OCw0MS44MDEgTDEyLjMyOCw0MS45MTkgTDUuNzcyLDQ1LjcwNCBDNS43NDEsNDUuNzIgMy43ODIsNDYuNzcyIDMuMTA2LDUwLjcyMiBDMy4wOTksNTAuNzgyIDIuMTk4LDU3LjgwOCAyLjIxMiw3MC40MjQgQzIuMjI2LDgyLjk2MyAyLjgwOSw4Ny40MiAzLjM3Myw5MS43MjkgQzMuNDY0LDkyLjQyIDQuMDYyLDkyLjg4MyA0LjY4Miw5My4xODEgQzQuNTY2LDkyLjk4NCA0LjQ4Niw5Mi43NzYgNC40NDYsOTIuNTcyIEMzLjY2NSw4OC41ODggMy4yOTEsODQuMzcgMy4yNzYsNzEuMTczIEMzLjI2Miw1OC41MiA0LjE2Nyw1MS40NjYgNC4xNzYsNTEuMzk2IEM0LjkwMSw0Ny4xNjUgNy4wMDgsNDYuMDU5IDcuMDk4LDQ2LjAxNCBDNy4wOTQsNDYuMDE1IDgxLjU0MiwzLjAzNCA4MS45NDQsMi44MDIgTDgxLjk3MiwyLjc4NSBDODIuODc2LDIuMjQ3IDgzLjY5MiwyLjA5NyA4NC4zMzIsMi4zNTIgQzg0Ljg4NywyLjU3MyA4NS4yODEsMy4wODUgODUuNTA0LDMuODcyIEM4Ny41MTgsMTEgODYuOTY0LDM2LjA5MSA4NS43ODUsNDMuODU1IEM4NS4yNzgsNDcuMTk2IDg0LjIxLDQ5LjM3IDgyLjYxLDUwLjMxNyBMNy4zMzUsOTMuODY5IEM2Ljk5OSw5NC4wNjMgNi42NTgsOTQuMTYxIDYuMzE3LDk0LjE2MSBMNi4zMTcsOTQuMTYxIFogTTYuMTcsOTMuNjU0IEM2LjQ2Myw5My42OSA2Ljc3NCw5My42MTcgNy4wODUsOTMuNDM3IEw4Mi4zNTgsNDkuODg2IEM4NC4xODEsNDguODA4IDg0Ljk2LDQ1Ljk3MSA4NS4yOTIsNDMuNzggQzg2LjQ2NiwzNi4wNDkgODcuMDIzLDExLjA4NSA4NS4wMjQsNC4wMDggQzg0Ljg0NiwzLjM3NyA4NC41NTEsMi45NzYgODQuMTQ4LDIuODE2IEM4My42NjQsMi42MjMgODIuOTgyLDIuNzY0IDgyLjIyNywzLjIxMyBMODIuMTkzLDMuMjM0IEM4MS43OTEsMy40NjYgNy4zMzUsNDYuNDUyIDcuMzM1LDQ2LjQ1MiBDNy4zMDQsNDYuNDY5IDUuMzQ2LDQ3LjUyMSA0LjY2OSw1MS40NzEgQzQuNjYyLDUxLjUzIDMuNzYxLDU4LjU1NiAzLjc3NSw3MS4xNzMgQzMuNzksODQuMzI4IDQuMTYxLDg4LjUyNCA0LjkzNiw5Mi40NzYgQzUuMDI2LDkyLjkzNyA1LjQxMiw5My40NTkgNS45NzMsOTMuNjE1IEM2LjA4Nyw5My42NCA2LjE1OCw5My42NTIgNi4xNjksOTMuNjU0IEw2LjE3LDkzLjY1NCBMNi4xNyw5My42NTQgWiIgaWQ9IkZpbGwtMTIiIGZpbGw9IiM0NTVBNjQiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNy4zMTcsNjguOTgyIEM3LjgwNiw2OC43MDEgOC4yMDIsNjguOTI2IDguMjAyLDY5LjQ4NyBDOC4yMDIsNzAuMDQ3IDcuODA2LDcwLjczIDcuMzE3LDcxLjAxMiBDNi44MjksNzEuMjk0IDYuNDMzLDcxLjA2OSA2LjQzMyw3MC41MDggQzYuNDMzLDY5Ljk0OCA2LjgyOSw2OS4yNjUgNy4zMTcsNjguOTgyIiBpZD0iRmlsbC0xMyIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik02LjkyLDcxLjEzMyBDNi42MzEsNzEuMTMzIDYuNDMzLDcwLjkwNSA2LjQzMyw3MC41MDggQzYuNDMzLDY5Ljk0OCA2LjgyOSw2OS4yNjUgNy4zMTcsNjguOTgyIEM3LjQ2LDY4LjkgNy41OTUsNjguODYxIDcuNzE0LDY4Ljg2MSBDOC4wMDMsNjguODYxIDguMjAyLDY5LjA5IDguMjAyLDY5LjQ4NyBDOC4yMDIsNzAuMDQ3IDcuODA2LDcwLjczIDcuMzE3LDcxLjAxMiBDNy4xNzQsNzEuMDk0IDcuMDM5LDcxLjEzMyA2LjkyLDcxLjEzMyBNNy43MTQsNjguNjc0IEM3LjU1Nyw2OC42NzQgNy4zOTIsNjguNzIzIDcuMjI0LDY4LjgyMSBDNi42NzYsNjkuMTM4IDYuMjQ2LDY5Ljg3OSA2LjI0Niw3MC41MDggQzYuMjQ2LDcwLjk5NCA2LjUxNyw3MS4zMiA2LjkyLDcxLjMyIEM3LjA3OCw3MS4zMiA3LjI0Myw3MS4yNzEgNy40MTEsNzEuMTc0IEM3Ljk1OSw3MC44NTcgOC4zODksNzAuMTE3IDguMzg5LDY5LjQ4NyBDOC4zODksNjkuMDAxIDguMTE3LDY4LjY3NCA3LjcxNCw2OC42NzQiIGlkPSJGaWxsLTE0IiBmaWxsPSIjODA5N0EyIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTYuOTIsNzAuOTQ3IEM2LjY0OSw3MC45NDcgNi42MjEsNzAuNjQgNi42MjEsNzAuNTA4IEM2LjYyMSw3MC4wMTcgNi45ODIsNjkuMzkyIDcuNDExLDY5LjE0NSBDNy41MjEsNjkuMDgyIDcuNjI1LDY5LjA0OSA3LjcxNCw2OS4wNDkgQzcuOTg2LDY5LjA0OSA4LjAxNSw2OS4zNTUgOC4wMTUsNjkuNDg3IEM4LjAxNSw2OS45NzggNy42NTIsNzAuNjAzIDcuMjI0LDcwLjg1MSBDNy4xMTUsNzAuOTE0IDcuMDEsNzAuOTQ3IDYuOTIsNzAuOTQ3IE03LjcxNCw2OC44NjEgQzcuNTk1LDY4Ljg2MSA3LjQ2LDY4LjkgNy4zMTcsNjguOTgyIEM2LjgyOSw2OS4yNjUgNi40MzMsNjkuOTQ4IDYuNDMzLDcwLjUwOCBDNi40MzMsNzAuOTA1IDYuNjMxLDcxLjEzMyA2LjkyLDcxLjEzMyBDNy4wMzksNzEuMTMzIDcuMTc0LDcxLjA5NCA3LjMxNyw3MS4wMTIgQzcuODA2LDcwLjczIDguMjAyLDcwLjA0NyA4LjIwMiw2OS40ODcgQzguMjAyLDY5LjA5IDguMDAzLDY4Ljg2MSA3LjcxNCw2OC44NjEiIGlkPSJGaWxsLTE1IiBmaWxsPSIjODA5N0EyIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTcuNDQ0LDg1LjM1IEM3LjcwOCw4NS4xOTggNy45MjEsODUuMzE5IDcuOTIxLDg1LjYyMiBDNy45MjEsODUuOTI1IDcuNzA4LDg2LjI5MiA3LjQ0NCw4Ni40NDQgQzcuMTgxLDg2LjU5NyA2Ljk2Nyw4Ni40NzUgNi45NjcsODYuMTczIEM2Ljk2Nyw4NS44NzEgNy4xODEsODUuNTAyIDcuNDQ0LDg1LjM1IiBpZD0iRmlsbC0xNiIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik03LjIzLDg2LjUxIEM3LjA3NCw4Ni41MSA2Ljk2Nyw4Ni4zODcgNi45NjcsODYuMTczIEM2Ljk2Nyw4NS44NzEgNy4xODEsODUuNTAyIDcuNDQ0LDg1LjM1IEM3LjUyMSw4NS4zMDUgNy41OTQsODUuMjg0IDcuNjU4LDg1LjI4NCBDNy44MTQsODUuMjg0IDcuOTIxLDg1LjQwOCA3LjkyMSw4NS42MjIgQzcuOTIxLDg1LjkyNSA3LjcwOCw4Ni4yOTIgNy40NDQsODYuNDQ0IEM3LjM2Nyw4Ni40ODkgNy4yOTQsODYuNTEgNy4yMyw4Ni41MSBNNy42NTgsODUuMDk4IEM3LjU1OCw4NS4wOTggNy40NTUsODUuMTI3IDcuMzUxLDg1LjE4OCBDNy4wMzEsODUuMzczIDYuNzgxLDg1LjgwNiA2Ljc4MSw4Ni4xNzMgQzYuNzgxLDg2LjQ4MiA2Ljk2Niw4Ni42OTcgNy4yMyw4Ni42OTcgQzcuMzMsODYuNjk3IDcuNDMzLDg2LjY2NiA3LjUzOCw4Ni42MDcgQzcuODU4LDg2LjQyMiA4LjEwOCw4NS45ODkgOC4xMDgsODUuNjIyIEM4LjEwOCw4NS4zMTMgNy45MjMsODUuMDk4IDcuNjU4LDg1LjA5OCIgaWQ9IkZpbGwtMTciIGZpbGw9IiM4MDk3QTIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNy4yMyw4Ni4zMjIgTDcuMTU0LDg2LjE3MyBDNy4xNTQsODUuOTM4IDcuMzMzLDg1LjYyOSA3LjUzOCw4NS41MTIgTDcuNjU4LDg1LjQ3MSBMNy43MzQsODUuNjIyIEM3LjczNCw4NS44NTYgNy41NTUsODYuMTY0IDcuMzUxLDg2LjI4MiBMNy4yMyw4Ni4zMjIgTTcuNjU4LDg1LjI4NCBDNy41OTQsODUuMjg0IDcuNTIxLDg1LjMwNSA3LjQ0NCw4NS4zNSBDNy4xODEsODUuNTAyIDYuOTY3LDg1Ljg3MSA2Ljk2Nyw4Ni4xNzMgQzYuOTY3LDg2LjM4NyA3LjA3NCw4Ni41MSA3LjIzLDg2LjUxIEM3LjI5NCw4Ni41MSA3LjM2Nyw4Ni40ODkgNy40NDQsODYuNDQ0IEM3LjcwOCw4Ni4yOTIgNy45MjEsODUuOTI1IDcuOTIxLDg1LjYyMiBDNy45MjEsODUuNDA4IDcuODE0LDg1LjI4NCA3LjY1OCw4NS4yODQiIGlkPSJGaWxsLTE4IiBmaWxsPSIjODA5N0EyIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTc3LjI3OCw3Ljc2OSBMNzcuMjc4LDUxLjQzNiBMMTAuMjA4LDkwLjE2IEwxMC4yMDgsNDYuNDkzIEw3Ny4yNzgsNy43NjkiIGlkPSJGaWxsLTE5IiBmaWxsPSIjNDU1QTY0Ij48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEwLjA4Myw5MC4zNzUgTDEwLjA4Myw0Ni40MjEgTDEwLjE0Niw0Ni4zODUgTDc3LjQwMyw3LjU1NCBMNzcuNDAzLDUxLjUwOCBMNzcuMzQxLDUxLjU0NCBMMTAuMDgzLDkwLjM3NSBMMTAuMDgzLDkwLjM3NSBaIE0xMC4zMzMsNDYuNTY0IEwxMC4zMzMsODkuOTQ0IEw3Ny4xNTQsNTEuMzY1IEw3Ny4xNTQsNy45ODUgTDEwLjMzMyw0Ni41NjQgTDEwLjMzMyw0Ni41NjQgWiIgaWQ9IkZpbGwtMjAiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMjUuNzM3LDg4LjY0NyBMMTE4LjA5OCw5MS45ODEgTDExOC4wOTgsODQgTDEwNi42MzksODguNzEzIEwxMDYuNjM5LDk2Ljk4MiBMOTksMTAwLjMxNSBMMTEyLjM2OSwxMDMuOTYxIEwxMjUuNzM3LDg4LjY0NyIgaWQ9IkltcG9ydGVkLUxheWVycy1Db3B5LTIiIGZpbGw9IiM0NTVBNjQiIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+'; + function RotateInstructions() { + this.loadIcon_(); + var overlay = document.createElement('div'); + var s = overlay.style; + s.position = 'fixed'; + s.top = 0; + s.right = 0; + s.bottom = 0; + s.left = 0; + s.backgroundColor = 'gray'; + s.fontFamily = 'sans-serif'; + s.zIndex = 1000000; + var img = document.createElement('img'); + img.src = this.icon; + var s = img.style; + s.marginLeft = '25%'; + s.marginTop = '25%'; + s.width = '50%'; + overlay.appendChild(img); + var text = document.createElement('div'); + var s = text.style; + s.textAlign = 'center'; + s.fontSize = '16px'; + s.lineHeight = '24px'; + s.margin = '24px 25%'; + s.width = '50%'; + text.innerHTML = 'Place your phone into your Cardboard viewer.'; + overlay.appendChild(text); + var snackbar = document.createElement('div'); + var s = snackbar.style; + s.backgroundColor = '#CFD8DC'; + s.position = 'fixed'; + s.bottom = 0; + s.width = '100%'; + s.height = '48px'; + s.padding = '14px 24px'; + s.boxSizing = 'border-box'; + s.color = '#656A6B'; + overlay.appendChild(snackbar); + var snackbarText = document.createElement('div'); + snackbarText.style.float = 'left'; + snackbarText.innerHTML = 'No Cardboard viewer?'; + var snackbarButton = document.createElement('a'); + snackbarButton.href = 'https://www.google.com/get/cardboard/get-cardboard/'; + snackbarButton.innerHTML = 'get one'; + snackbarButton.target = '_blank'; + var s = snackbarButton.style; + s.float = 'right'; + s.fontWeight = 600; + s.textTransform = 'uppercase'; + s.borderLeft = '1px solid gray'; + s.paddingLeft = '24px'; + s.textDecoration = 'none'; + s.color = '#656A6B'; + snackbar.appendChild(snackbarText); + snackbar.appendChild(snackbarButton); + this.overlay = overlay; + this.text = text; + this.hide(); + } + RotateInstructions.prototype.show = function (parent) { + if (!parent && !this.overlay.parentElement) { + document.body.appendChild(this.overlay); + } else if (parent) { + if (this.overlay.parentElement && this.overlay.parentElement != parent) this.overlay.parentElement.removeChild(this.overlay); + parent.appendChild(this.overlay); + } + this.overlay.style.display = 'block'; + var img = this.overlay.querySelector('img'); + var s = img.style; + if (isLandscapeMode()) { + s.width = '20%'; + s.marginLeft = '40%'; + s.marginTop = '3%'; + } else { + s.width = '50%'; + s.marginLeft = '25%'; + s.marginTop = '25%'; + } + }; + RotateInstructions.prototype.hide = function () { + this.overlay.style.display = 'none'; + }; + RotateInstructions.prototype.showTemporarily = function (ms, parent) { + this.show(parent); + this.timer = setTimeout(this.hide.bind(this), ms); + }; + RotateInstructions.prototype.disableShowTemporarily = function () { + clearTimeout(this.timer); + }; + RotateInstructions.prototype.update = function () { + this.disableShowTemporarily(); + if (!isLandscapeMode() && isMobile()) { + this.show(); + } else { + this.hide(); + } + }; + RotateInstructions.prototype.loadIcon_ = function () { + this.icon = base64('image/svg+xml', rotateInstructionsAsset); + }; + var DEFAULT_VIEWER = 'CardboardV1'; + var VIEWER_KEY = 'WEBVR_CARDBOARD_VIEWER'; + var CLASS_NAME = 'webvr-polyfill-viewer-selector'; + function ViewerSelector(defaultViewer) { + try { + this.selectedKey = localStorage.getItem(VIEWER_KEY); + } catch (error) { + console.error('Failed to load viewer profile: %s', error); + } + if (!this.selectedKey) { + this.selectedKey = defaultViewer || DEFAULT_VIEWER; + } + this.dialog = this.createDialog_(DeviceInfo.Viewers); + this.root = null; + this.onChangeCallbacks_ = []; + } + ViewerSelector.prototype.show = function (root) { + this.root = root; + root.appendChild(this.dialog); + var selected = this.dialog.querySelector('#' + this.selectedKey); + selected.checked = true; + this.dialog.style.display = 'block'; + }; + ViewerSelector.prototype.hide = function () { + if (this.root && this.root.contains(this.dialog)) { + this.root.removeChild(this.dialog); + } + this.dialog.style.display = 'none'; + }; + ViewerSelector.prototype.getCurrentViewer = function () { + return DeviceInfo.Viewers[this.selectedKey]; + }; + ViewerSelector.prototype.getSelectedKey_ = function () { + var input = this.dialog.querySelector('input[name=field]:checked'); + if (input) { + return input.id; + } + return null; + }; + ViewerSelector.prototype.onChange = function (cb) { + this.onChangeCallbacks_.push(cb); + }; + ViewerSelector.prototype.fireOnChange_ = function (viewer) { + for (var i = 0; i < this.onChangeCallbacks_.length; i++) { + this.onChangeCallbacks_[i](viewer); + } + }; + ViewerSelector.prototype.onSave_ = function () { + this.selectedKey = this.getSelectedKey_(); + if (!this.selectedKey || !DeviceInfo.Viewers[this.selectedKey]) { + console.error('ViewerSelector.onSave_: this should never happen!'); + return; + } + this.fireOnChange_(DeviceInfo.Viewers[this.selectedKey]); + try { + localStorage.setItem(VIEWER_KEY, this.selectedKey); + } catch (error) { + console.error('Failed to save viewer profile: %s', error); + } + this.hide(); + }; + ViewerSelector.prototype.createDialog_ = function (options) { + var container = document.createElement('div'); + container.classList.add(CLASS_NAME); + container.style.display = 'none'; + var overlay = document.createElement('div'); + var s = overlay.style; + s.position = 'fixed'; + s.left = 0; + s.top = 0; + s.width = '100%'; + s.height = '100%'; + s.background = 'rgba(0, 0, 0, 0.3)'; + overlay.addEventListener('click', this.hide.bind(this)); + var width = 280; + var dialog = document.createElement('div'); + var s = dialog.style; + s.boxSizing = 'border-box'; + s.position = 'fixed'; + s.top = '24px'; + s.left = '50%'; + s.marginLeft = -width / 2 + 'px'; + s.width = width + 'px'; + s.padding = '24px'; + s.overflow = 'hidden'; + s.background = '#fafafa'; + s.fontFamily = "'Roboto', sans-serif"; + s.boxShadow = '0px 5px 20px #666'; + dialog.appendChild(this.createH1_('Select your viewer')); + for (var id in options) { + dialog.appendChild(this.createChoice_(id, options[id].label)); + } + dialog.appendChild(this.createButton_('Save', this.onSave_.bind(this))); + container.appendChild(overlay); + container.appendChild(dialog); + return container; + }; + ViewerSelector.prototype.createH1_ = function (name) { + var h1 = document.createElement('h1'); + var s = h1.style; + s.color = 'black'; + s.fontSize = '20px'; + s.fontWeight = 'bold'; + s.marginTop = 0; + s.marginBottom = '24px'; + h1.innerHTML = name; + return h1; + }; + ViewerSelector.prototype.createChoice_ = function (id, name) { + var div = document.createElement('div'); + div.style.marginTop = '8px'; + div.style.color = 'black'; + var input = document.createElement('input'); + input.style.fontSize = '30px'; + input.setAttribute('id', id); + input.setAttribute('type', 'radio'); + input.setAttribute('value', id); + input.setAttribute('name', 'field'); + var label = document.createElement('label'); + label.style.marginLeft = '4px'; + label.setAttribute('for', id); + label.innerHTML = name; + div.appendChild(input); + div.appendChild(label); + return div; + }; + ViewerSelector.prototype.createButton_ = function (label, onclick) { + var button = document.createElement('button'); + button.innerHTML = label; + var s = button.style; + s.float = 'right'; + s.textTransform = 'uppercase'; + s.color = '#1094f7'; + s.fontSize = '14px'; + s.letterSpacing = 0; + s.border = 0; + s.background = 'none'; + s.marginTop = '16px'; + button.addEventListener('click', onclick); + return button; + }; + var commonjsGlobal$$1 = typeof window !== 'undefined' ? window : typeof commonjsGlobal$1 !== 'undefined' ? commonjsGlobal$1 : typeof self !== 'undefined' ? self : {}; + function unwrapExports$$1 (x) { + return x && x.__esModule ? x['default'] : x; + } + function createCommonjsModule$$1(fn, module) { + return module = { exports: {} }, fn(module, module.exports), module.exports; + } + var NoSleep = createCommonjsModule$$1(function (module, exports) { + (function webpackUniversalModuleDefinition(root, factory) { + module.exports = factory(); + })(commonjsGlobal$$1, function() { + return (function(modules) { + var installedModules = {}; + function __webpack_require__(moduleId) { + if(installedModules[moduleId]) { + return installedModules[moduleId].exports; + } + var module = installedModules[moduleId] = { + i: moduleId, + l: false, + exports: {} + }; + modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); + module.l = true; + return module.exports; + } + __webpack_require__.m = modules; + __webpack_require__.c = installedModules; + __webpack_require__.d = function(exports, name, getter) { + if(!__webpack_require__.o(exports, name)) { + Object.defineProperty(exports, name, { + configurable: false, + enumerable: true, + get: getter + }); + } + }; + __webpack_require__.n = function(module) { + var getter = module && module.__esModule ? + function getDefault() { return module['default']; } : + function getModuleExports() { return module; }; + __webpack_require__.d(getter, 'a', getter); + return getter; + }; + __webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); }; + __webpack_require__.p = ""; + return __webpack_require__(__webpack_require__.s = 0); + }) + ([ + (function(module, exports, __webpack_require__) { + var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }(); + function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + var mediaFile = __webpack_require__(1); + var oldIOS = typeof navigator !== 'undefined' && parseFloat(('' + (/CPU.*OS ([0-9_]{3,4})[0-9_]{0,1}|(CPU like).*AppleWebKit.*Mobile/i.exec(navigator.userAgent) || [0, ''])[1]).replace('undefined', '3_2').replace('_', '.').replace('_', '')) < 10 && !window.MSStream; + var NoSleep = function () { + function NoSleep() { + _classCallCheck(this, NoSleep); + if (oldIOS) { + this.noSleepTimer = null; + } else { + this.noSleepVideo = document.createElement('video'); + this.noSleepVideo.setAttribute('playsinline', ''); + this.noSleepVideo.setAttribute('src', mediaFile); + this.noSleepVideo.addEventListener('timeupdate', function (e) { + if (this.noSleepVideo.currentTime > 0.5) { + this.noSleepVideo.currentTime = Math.random(); + } + }.bind(this)); + } + } + _createClass(NoSleep, [{ + key: 'enable', + value: function enable() { + if (oldIOS) { + this.disable(); + this.noSleepTimer = window.setInterval(function () { + window.location.href = '/'; + window.setTimeout(window.stop, 0); + }, 15000); + } else { + this.noSleepVideo.play(); + } + } + }, { + key: 'disable', + value: function disable() { + if (oldIOS) { + if (this.noSleepTimer) { + window.clearInterval(this.noSleepTimer); + this.noSleepTimer = null; + } + } else { + this.noSleepVideo.pause(); + } + } + }]); + return NoSleep; + }(); + module.exports = NoSleep; + }), + (function(module, exports, __webpack_require__) { + module.exports = 'data:video/mp4;base64,AAAAIGZ0eXBtcDQyAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAACKBtZGF0AAAC8wYF///v3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0MiByMjQ3OSBkZDc5YTYxIC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNCAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTEgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MToweDExMSBtZT1oZXggc3VibWU9MiBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0wIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MCA4eDhkY3Q9MCBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0wIHRocmVhZHM9NiBsb29rYWhlYWRfdGhyZWFkcz0xIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTMgYl9weXJhbWlkPTIgYl9hZGFwdD0xIGJfYmlhcz0wIGRpcmVjdD0xIHdlaWdodGI9MSBvcGVuX2dvcD0wIHdlaWdodHA9MSBrZXlpbnQ9MzAwIGtleWludF9taW49MzAgc2NlbmVjdXQ9NDAgaW50cmFfcmVmcmVzaD0wIHJjX2xvb2thaGVhZD0xMCByYz1jcmYgbWJ0cmVlPTEgY3JmPTIwLjAgcWNvbXA9MC42MCBxcG1pbj0wIHFwbWF4PTY5IHFwc3RlcD00IHZidl9tYXhyYXRlPTIwMDAwIHZidl9idWZzaXplPTI1MDAwIGNyZl9tYXg9MC4wIG5hbF9ocmQ9bm9uZSBmaWxsZXI9MCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAOWWIhAA3//p+C7v8tDDSTjf97w55i3SbRPO4ZY+hkjD5hbkAkL3zpJ6h/LR1CAABzgB1kqqzUorlhQAAAAxBmiQYhn/+qZYADLgAAAAJQZ5CQhX/AAj5IQADQGgcIQADQGgcAAAACQGeYUQn/wALKCEAA0BoHAAAAAkBnmNEJ/8ACykhAANAaBwhAANAaBwAAAANQZpoNExDP/6plgAMuSEAA0BoHAAAAAtBnoZFESwr/wAI+SEAA0BoHCEAA0BoHAAAAAkBnqVEJ/8ACykhAANAaBwAAAAJAZ6nRCf/AAsoIQADQGgcIQADQGgcAAAADUGarDRMQz/+qZYADLghAANAaBwAAAALQZ7KRRUsK/8ACPkhAANAaBwAAAAJAZ7pRCf/AAsoIQADQGgcIQADQGgcAAAACQGe60Qn/wALKCEAA0BoHAAAAA1BmvA0TEM//qmWAAy5IQADQGgcIQADQGgcAAAAC0GfDkUVLCv/AAj5IQADQGgcAAAACQGfLUQn/wALKSEAA0BoHCEAA0BoHAAAAAkBny9EJ/8ACyghAANAaBwAAAANQZs0NExDP/6plgAMuCEAA0BoHAAAAAtBn1JFFSwr/wAI+SEAA0BoHCEAA0BoHAAAAAkBn3FEJ/8ACyghAANAaBwAAAAJAZ9zRCf/AAsoIQADQGgcIQADQGgcAAAADUGbeDRMQz/+qZYADLkhAANAaBwAAAALQZ+WRRUsK/8ACPghAANAaBwhAANAaBwAAAAJAZ+1RCf/AAspIQADQGgcAAAACQGft0Qn/wALKSEAA0BoHCEAA0BoHAAAAA1Bm7w0TEM//qmWAAy4IQADQGgcAAAAC0Gf2kUVLCv/AAj5IQADQGgcAAAACQGf+UQn/wALKCEAA0BoHCEAA0BoHAAAAAkBn/tEJ/8ACykhAANAaBwAAAANQZvgNExDP/6plgAMuSEAA0BoHCEAA0BoHAAAAAtBnh5FFSwr/wAI+CEAA0BoHAAAAAkBnj1EJ/8ACyghAANAaBwhAANAaBwAAAAJAZ4/RCf/AAspIQADQGgcAAAADUGaJDRMQz/+qZYADLghAANAaBwAAAALQZ5CRRUsK/8ACPkhAANAaBwhAANAaBwAAAAJAZ5hRCf/AAsoIQADQGgcAAAACQGeY0Qn/wALKSEAA0BoHCEAA0BoHAAAAA1Bmmg0TEM//qmWAAy5IQADQGgcAAAAC0GehkUVLCv/AAj5IQADQGgcIQADQGgcAAAACQGepUQn/wALKSEAA0BoHAAAAAkBnqdEJ/8ACyghAANAaBwAAAANQZqsNExDP/6plgAMuCEAA0BoHCEAA0BoHAAAAAtBnspFFSwr/wAI+SEAA0BoHAAAAAkBnulEJ/8ACyghAANAaBwhAANAaBwAAAAJAZ7rRCf/AAsoIQADQGgcAAAADUGa8DRMQz/+qZYADLkhAANAaBwhAANAaBwAAAALQZ8ORRUsK/8ACPkhAANAaBwAAAAJAZ8tRCf/AAspIQADQGgcIQADQGgcAAAACQGfL0Qn/wALKCEAA0BoHAAAAA1BmzQ0TEM//qmWAAy4IQADQGgcAAAAC0GfUkUVLCv/AAj5IQADQGgcIQADQGgcAAAACQGfcUQn/wALKCEAA0BoHAAAAAkBn3NEJ/8ACyghAANAaBwhAANAaBwAAAANQZt4NExC//6plgAMuSEAA0BoHAAAAAtBn5ZFFSwr/wAI+CEAA0BoHCEAA0BoHAAAAAkBn7VEJ/8ACykhAANAaBwAAAAJAZ+3RCf/AAspIQADQGgcAAAADUGbuzRMQn/+nhAAYsAhAANAaBwhAANAaBwAAAAJQZ/aQhP/AAspIQADQGgcAAAACQGf+UQn/wALKCEAA0BoHCEAA0BoHCEAA0BoHCEAA0BoHCEAA0BoHCEAA0BoHAAACiFtb292AAAAbG12aGQAAAAA1YCCX9WAgl8AAAPoAAAH/AABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAGGlvZHMAAAAAEICAgAcAT////v7/AAAF+XRyYWsAAABcdGtoZAAAAAPVgIJf1YCCXwAAAAEAAAAAAAAH0AAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAygAAAMoAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAB9AAABdwAAEAAAAABXFtZGlhAAAAIG1kaGQAAAAA1YCCX9WAgl8AAV+QAAK/IFXEAAAAAAAtaGRscgAAAAAAAAAAdmlkZQAAAAAAAAAAAAAAAFZpZGVvSGFuZGxlcgAAAAUcbWluZgAAABR2bWhkAAAAAQAAAAAAAAAAAAAAJGRpbmYAAAAcZHJlZgAAAAAAAAABAAAADHVybCAAAAABAAAE3HN0YmwAAACYc3RzZAAAAAAAAAABAAAAiGF2YzEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAygDKAEgAAABIAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY//8AAAAyYXZjQwFNQCj/4QAbZ01AKOyho3ySTUBAQFAAAAMAEAAr8gDxgxlgAQAEaO+G8gAAABhzdHRzAAAAAAAAAAEAAAA8AAALuAAAABRzdHNzAAAAAAAAAAEAAAABAAAB8GN0dHMAAAAAAAAAPAAAAAEAABdwAAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAADqYAAAAAQAAF3AAAAABAAAAAAAAAAEAAAu4AAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAADqYAAAAAQAAF3AAAAABAAAAAAAAAAEAAAu4AAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAADqYAAAAAQAAF3AAAAABAAAAAAAAAAEAAAu4AAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAADqYAAAAAQAAF3AAAAABAAAAAAAAAAEAAAu4AAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAAC7gAAAAAQAAF3AAAAABAAAAAAAAABxzdHNjAAAAAAAAAAEAAAABAAAAAQAAAAEAAAEEc3RzegAAAAAAAAAAAAAAPAAAAzQAAAAQAAAADQAAAA0AAAANAAAAEQAAAA8AAAANAAAADQAAABEAAAAPAAAADQAAAA0AAAARAAAADwAAAA0AAAANAAAAEQAAAA8AAAANAAAADQAAABEAAAAPAAAADQAAAA0AAAARAAAADwAAAA0AAAANAAAAEQAAAA8AAAANAAAADQAAABEAAAAPAAAADQAAAA0AAAARAAAADwAAAA0AAAANAAAAEQAAAA8AAAANAAAADQAAABEAAAAPAAAADQAAAA0AAAARAAAADwAAAA0AAAANAAAAEQAAAA8AAAANAAAADQAAABEAAAANAAAADQAAAQBzdGNvAAAAAAAAADwAAAAwAAADZAAAA3QAAAONAAADoAAAA7kAAAPQAAAD6wAAA/4AAAQXAAAELgAABEMAAARcAAAEbwAABIwAAAShAAAEugAABM0AAATkAAAE/wAABRIAAAUrAAAFQgAABV0AAAVwAAAFiQAABaAAAAW1AAAFzgAABeEAAAX+AAAGEwAABiwAAAY/AAAGVgAABnEAAAaEAAAGnQAABrQAAAbPAAAG4gAABvUAAAcSAAAHJwAAB0AAAAdTAAAHcAAAB4UAAAeeAAAHsQAAB8gAAAfjAAAH9gAACA8AAAgmAAAIQQAACFQAAAhnAAAIhAAACJcAAAMsdHJhawAAAFx0a2hkAAAAA9WAgl/VgIJfAAAAAgAAAAAAAAf8AAAAAAAAAAAAAAABAQAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAACsm1kaWEAAAAgbWRoZAAAAADVgIJf1YCCXwAArEQAAWAAVcQAAAAAACdoZGxyAAAAAAAAAABzb3VuAAAAAAAAAAAAAAAAU3RlcmVvAAAAAmNtaW5mAAAAEHNtaGQAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAidzdGJsAAAAZ3N0c2QAAAAAAAAAAQAAAFdtcDRhAAAAAAAAAAEAAAAAAAAAAAACABAAAAAArEQAAAAAADNlc2RzAAAAAAOAgIAiAAIABICAgBRAFQAAAAADDUAAAAAABYCAgAISEAaAgIABAgAAABhzdHRzAAAAAAAAAAEAAABYAAAEAAAAABxzdHNjAAAAAAAAAAEAAAABAAAAAQAAAAEAAAAUc3RzegAAAAAAAAAGAAAAWAAAAXBzdGNvAAAAAAAAAFgAAAOBAAADhwAAA5oAAAOtAAADswAAA8oAAAPfAAAD5QAAA/gAAAQLAAAEEQAABCgAAAQ9AAAEUAAABFYAAARpAAAEgAAABIYAAASbAAAErgAABLQAAATHAAAE3gAABPMAAAT5AAAFDAAABR8AAAUlAAAFPAAABVEAAAVXAAAFagAABX0AAAWDAAAFmgAABa8AAAXCAAAFyAAABdsAAAXyAAAF+AAABg0AAAYgAAAGJgAABjkAAAZQAAAGZQAABmsAAAZ+AAAGkQAABpcAAAauAAAGwwAABskAAAbcAAAG7wAABwYAAAcMAAAHIQAABzQAAAc6AAAHTQAAB2QAAAdqAAAHfwAAB5IAAAeYAAAHqwAAB8IAAAfXAAAH3QAAB/AAAAgDAAAICQAACCAAAAg1AAAIOwAACE4AAAhhAAAIeAAACH4AAAiRAAAIpAAACKoAAAiwAAAItgAACLwAAAjCAAAAFnVkdGEAAAAObmFtZVN0ZXJlbwAAAHB1ZHRhAAAAaG1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAAO2lsc3QAAAAzqXRvbwAAACtkYXRhAAAAAQAAAABIYW5kQnJha2UgMC4xMC4yIDIwMTUwNjExMDA='; + }) + ]); + }); + }); + var NoSleep$1 = unwrapExports$$1(NoSleep); + var nextDisplayId = 1000; + var defaultLeftBounds = [0, 0, 0.5, 1]; + var defaultRightBounds = [0.5, 0, 0.5, 1]; + var raf = window.requestAnimationFrame; + var caf = window.cancelAnimationFrame; + function VRFrameData() { + this.leftProjectionMatrix = new Float32Array(16); + this.leftViewMatrix = new Float32Array(16); + this.rightProjectionMatrix = new Float32Array(16); + this.rightViewMatrix = new Float32Array(16); + this.pose = null; + } + function VRDisplayCapabilities(config) { + Object.defineProperties(this, { + hasPosition: { + writable: false, enumerable: true, value: config.hasPosition + }, + hasExternalDisplay: { + writable: false, enumerable: true, value: config.hasExternalDisplay + }, + canPresent: { + writable: false, enumerable: true, value: config.canPresent + }, + maxLayers: { + writable: false, enumerable: true, value: config.maxLayers + }, + hasOrientation: { + enumerable: true, get: function get() { + deprecateWarning('VRDisplayCapabilities.prototype.hasOrientation', 'VRDisplay.prototype.getFrameData'); + return config.hasOrientation; + } + } + }); + } + function VRDisplay(config) { + config = config || {}; + var USE_WAKELOCK = 'wakelock' in config ? config.wakelock : true; + this.isPolyfilled = true; + this.displayId = nextDisplayId++; + this.displayName = ''; + this.depthNear = 0.01; + this.depthFar = 10000.0; + this.isPresenting = false; + Object.defineProperty(this, 'isConnected', { + get: function get() { + deprecateWarning('VRDisplay.prototype.isConnected', 'VRDisplayCapabilities.prototype.hasExternalDisplay'); + return false; + } + }); + this.capabilities = new VRDisplayCapabilities({ + hasPosition: false, + hasOrientation: false, + hasExternalDisplay: false, + canPresent: false, + maxLayers: 1 + }); + this.stageParameters = null; + this.waitingForPresent_ = false; + this.layer_ = null; + this.originalParent_ = null; + this.fullscreenElement_ = null; + this.fullscreenWrapper_ = null; + this.fullscreenElementCachedStyle_ = null; + this.fullscreenEventTarget_ = null; + this.fullscreenChangeHandler_ = null; + this.fullscreenErrorHandler_ = null; + if (USE_WAKELOCK && isMobile()) { + this.wakelock_ = new NoSleep$1(); + } + } + VRDisplay.prototype.getFrameData = function (frameData) { + return frameDataFromPose(frameData, this._getPose(), this); + }; + VRDisplay.prototype.getPose = function () { + deprecateWarning('VRDisplay.prototype.getPose', 'VRDisplay.prototype.getFrameData'); + return this._getPose(); + }; + VRDisplay.prototype.resetPose = function () { + deprecateWarning('VRDisplay.prototype.resetPose'); + return this._resetPose(); + }; + VRDisplay.prototype.getImmediatePose = function () { + deprecateWarning('VRDisplay.prototype.getImmediatePose', 'VRDisplay.prototype.getFrameData'); + return this._getPose(); + }; + VRDisplay.prototype.requestAnimationFrame = function (callback) { + return raf(callback); + }; + VRDisplay.prototype.cancelAnimationFrame = function (id) { + return caf(id); + }; + VRDisplay.prototype.wrapForFullscreen = function (element) { + if (isIOS()) { + return element; + } + if (!this.fullscreenWrapper_) { + this.fullscreenWrapper_ = document.createElement('div'); + var cssProperties = ['height: ' + Math.min(screen.height, screen.width) + 'px !important', 'top: 0 !important', 'left: 0 !important', 'right: 0 !important', 'border: 0', 'margin: 0', 'padding: 0', 'z-index: 999999 !important', 'position: fixed']; + this.fullscreenWrapper_.setAttribute('style', cssProperties.join('; ') + ';'); + this.fullscreenWrapper_.classList.add('webvr-polyfill-fullscreen-wrapper'); + } + if (this.fullscreenElement_ == element) { + return this.fullscreenWrapper_; + } + if (this.fullscreenElement_) { + if (this.originalParent_) { + this.originalParent_.appendChild(this.fullscreenElement_); + } else { + this.fullscreenElement_.parentElement.removeChild(this.fullscreenElement_); + } + } + this.fullscreenElement_ = element; + this.originalParent_ = element.parentElement; + if (!this.originalParent_) { + document.body.appendChild(element); + } + if (!this.fullscreenWrapper_.parentElement) { + var parent = this.fullscreenElement_.parentElement; + parent.insertBefore(this.fullscreenWrapper_, this.fullscreenElement_); + parent.removeChild(this.fullscreenElement_); + } + this.fullscreenWrapper_.insertBefore(this.fullscreenElement_, this.fullscreenWrapper_.firstChild); + this.fullscreenElementCachedStyle_ = this.fullscreenElement_.getAttribute('style'); + var self = this; + function applyFullscreenElementStyle() { + if (!self.fullscreenElement_) { + return; + } + var cssProperties = ['position: absolute', 'top: 0', 'left: 0', 'width: ' + Math.max(screen.width, screen.height) + 'px', 'height: ' + Math.min(screen.height, screen.width) + 'px', 'border: 0', 'margin: 0', 'padding: 0']; + self.fullscreenElement_.setAttribute('style', cssProperties.join('; ') + ';'); + } + applyFullscreenElementStyle(); + return this.fullscreenWrapper_; + }; + VRDisplay.prototype.removeFullscreenWrapper = function () { + if (!this.fullscreenElement_) { + return; + } + var element = this.fullscreenElement_; + if (this.fullscreenElementCachedStyle_) { + element.setAttribute('style', this.fullscreenElementCachedStyle_); + } else { + element.removeAttribute('style'); + } + this.fullscreenElement_ = null; + this.fullscreenElementCachedStyle_ = null; + var parent = this.fullscreenWrapper_.parentElement; + this.fullscreenWrapper_.removeChild(element); + if (this.originalParent_ === parent) { + parent.insertBefore(element, this.fullscreenWrapper_); + } + else if (this.originalParent_) { + this.originalParent_.appendChild(element); + } + parent.removeChild(this.fullscreenWrapper_); + return element; + }; + VRDisplay.prototype.requestPresent = function (layers) { + var wasPresenting = this.isPresenting; + var self = this; + if (!(layers instanceof Array)) { + deprecateWarning('VRDisplay.prototype.requestPresent with non-array argument', 'an array of VRLayers as the first argument'); + layers = [layers]; + } + return new Promise(function (resolve, reject) { + if (!self.capabilities.canPresent) { + reject(new Error('VRDisplay is not capable of presenting.')); + return; + } + if (layers.length == 0 || layers.length > self.capabilities.maxLayers) { + reject(new Error('Invalid number of layers.')); + return; + } + var incomingLayer = layers[0]; + if (!incomingLayer.source) { + resolve(); + return; + } + var leftBounds = incomingLayer.leftBounds || defaultLeftBounds; + var rightBounds = incomingLayer.rightBounds || defaultRightBounds; + if (wasPresenting) { + var layer = self.layer_; + if (layer.source !== incomingLayer.source) { + layer.source = incomingLayer.source; + } + for (var i = 0; i < 4; i++) { + layer.leftBounds[i] = leftBounds[i]; + layer.rightBounds[i] = rightBounds[i]; + } + self.wrapForFullscreen(self.layer_.source); + self.updatePresent_(); + resolve(); + return; + } + self.layer_ = { + predistorted: incomingLayer.predistorted, + source: incomingLayer.source, + leftBounds: leftBounds.slice(0), + rightBounds: rightBounds.slice(0) + }; + self.waitingForPresent_ = false; + if (self.layer_ && self.layer_.source) { + var fullscreenElement = self.wrapForFullscreen(self.layer_.source); + var onFullscreenChange = function onFullscreenChange() { + var actualFullscreenElement = getFullscreenElement(); + self.isPresenting = fullscreenElement === actualFullscreenElement; + if (self.isPresenting) { + if (screen.orientation && screen.orientation.lock) { + screen.orientation.lock('landscape-primary').catch(function (error) { + console.error('screen.orientation.lock() failed due to', error.message); + }); + } + self.waitingForPresent_ = false; + self.beginPresent_(); + resolve(); + } else { + if (screen.orientation && screen.orientation.unlock) { + screen.orientation.unlock(); + } + self.removeFullscreenWrapper(); + self.disableWakeLock(); + self.endPresent_(); + self.removeFullscreenListeners_(); + } + self.fireVRDisplayPresentChange_(); + }; + var onFullscreenError = function onFullscreenError() { + if (!self.waitingForPresent_) { + return; + } + self.removeFullscreenWrapper(); + self.removeFullscreenListeners_(); + self.disableWakeLock(); + self.waitingForPresent_ = false; + self.isPresenting = false; + reject(new Error('Unable to present.')); + }; + self.addFullscreenListeners_(fullscreenElement, onFullscreenChange, onFullscreenError); + if (requestFullscreen(fullscreenElement)) { + self.enableWakeLock(); + self.waitingForPresent_ = true; + } else if (isIOS() || isWebViewAndroid()) { + self.enableWakeLock(); + self.isPresenting = true; + self.beginPresent_(); + self.fireVRDisplayPresentChange_(); + resolve(); + } + } + if (!self.waitingForPresent_ && !isIOS()) { + exitFullscreen(); + reject(new Error('Unable to present.')); + } + }); + }; + VRDisplay.prototype.exitPresent = function () { + var wasPresenting = this.isPresenting; + var self = this; + this.isPresenting = false; + this.layer_ = null; + this.disableWakeLock(); + return new Promise(function (resolve, reject) { + if (wasPresenting) { + if (!exitFullscreen() && isIOS()) { + self.endPresent_(); + self.fireVRDisplayPresentChange_(); + } + if (isWebViewAndroid()) { + self.removeFullscreenWrapper(); + self.removeFullscreenListeners_(); + self.endPresent_(); + self.fireVRDisplayPresentChange_(); + } + resolve(); + } else { + reject(new Error('Was not presenting to VRDisplay.')); + } + }); + }; + VRDisplay.prototype.getLayers = function () { + if (this.layer_) { + return [this.layer_]; + } + return []; + }; + VRDisplay.prototype.fireVRDisplayPresentChange_ = function () { + var event = new CustomEvent('vrdisplaypresentchange', { detail: { display: this } }); + window.dispatchEvent(event); + }; + VRDisplay.prototype.fireVRDisplayConnect_ = function () { + var event = new CustomEvent('vrdisplayconnect', { detail: { display: this } }); + window.dispatchEvent(event); + }; + VRDisplay.prototype.addFullscreenListeners_ = function (element, changeHandler, errorHandler) { + this.removeFullscreenListeners_(); + this.fullscreenEventTarget_ = element; + this.fullscreenChangeHandler_ = changeHandler; + this.fullscreenErrorHandler_ = errorHandler; + if (changeHandler) { + if (document.fullscreenEnabled) { + element.addEventListener('fullscreenchange', changeHandler, false); + } else if (document.webkitFullscreenEnabled) { + element.addEventListener('webkitfullscreenchange', changeHandler, false); + } else if (document.mozFullScreenEnabled) { + document.addEventListener('mozfullscreenchange', changeHandler, false); + } else if (document.msFullscreenEnabled) { + element.addEventListener('msfullscreenchange', changeHandler, false); + } + } + if (errorHandler) { + if (document.fullscreenEnabled) { + element.addEventListener('fullscreenerror', errorHandler, false); + } else if (document.webkitFullscreenEnabled) { + element.addEventListener('webkitfullscreenerror', errorHandler, false); + } else if (document.mozFullScreenEnabled) { + document.addEventListener('mozfullscreenerror', errorHandler, false); + } else if (document.msFullscreenEnabled) { + element.addEventListener('msfullscreenerror', errorHandler, false); + } + } + }; + VRDisplay.prototype.removeFullscreenListeners_ = function () { + if (!this.fullscreenEventTarget_) return; + var element = this.fullscreenEventTarget_; + if (this.fullscreenChangeHandler_) { + var changeHandler = this.fullscreenChangeHandler_; + element.removeEventListener('fullscreenchange', changeHandler, false); + element.removeEventListener('webkitfullscreenchange', changeHandler, false); + document.removeEventListener('mozfullscreenchange', changeHandler, false); + element.removeEventListener('msfullscreenchange', changeHandler, false); + } + if (this.fullscreenErrorHandler_) { + var errorHandler = this.fullscreenErrorHandler_; + element.removeEventListener('fullscreenerror', errorHandler, false); + element.removeEventListener('webkitfullscreenerror', errorHandler, false); + document.removeEventListener('mozfullscreenerror', errorHandler, false); + element.removeEventListener('msfullscreenerror', errorHandler, false); + } + this.fullscreenEventTarget_ = null; + this.fullscreenChangeHandler_ = null; + this.fullscreenErrorHandler_ = null; + }; + VRDisplay.prototype.enableWakeLock = function () { + if (this.wakelock_) { + this.wakelock_.enable(); + } + }; + VRDisplay.prototype.disableWakeLock = function () { + if (this.wakelock_) { + this.wakelock_.disable(); + } + }; + VRDisplay.prototype.beginPresent_ = function () { + }; + VRDisplay.prototype.endPresent_ = function () { + }; + VRDisplay.prototype.submitFrame = function (pose) { + }; + VRDisplay.prototype.getEyeParameters = function (whichEye) { + return null; + }; + var config = { + ADDITIONAL_VIEWERS: [], + DEFAULT_VIEWER: '', + MOBILE_WAKE_LOCK: true, + DEBUG: false, + DPDB_URL: 'https://dpdb.webvr.rocks/dpdb.json', + K_FILTER: 0.98, + PREDICTION_TIME_S: 0.040, + CARDBOARD_UI_DISABLED: false, + ROTATE_INSTRUCTIONS_DISABLED: false, + YAW_ONLY: false, + BUFFER_SCALE: 0.5, + DIRTY_SUBMIT_FRAME_BINDINGS: false + }; + var Eye = { + LEFT: 'left', + RIGHT: 'right' + }; + function CardboardVRDisplay(config$$1) { + var defaults = extend({}, config); + config$$1 = extend(defaults, config$$1 || {}); + VRDisplay.call(this, { + wakelock: config$$1.MOBILE_WAKE_LOCK + }); + this.config = config$$1; + this.displayName = 'Cardboard VRDisplay'; + this.capabilities = new VRDisplayCapabilities({ + hasPosition: false, + hasOrientation: true, + hasExternalDisplay: false, + canPresent: true, + maxLayers: 1 + }); + this.stageParameters = null; + this.bufferScale_ = this.config.BUFFER_SCALE; + this.poseSensor_ = new PoseSensor(this.config); + this.distorter_ = null; + this.cardboardUI_ = null; + this.dpdb_ = new Dpdb(this.config.DPDB_URL, this.onDeviceParamsUpdated_.bind(this)); + this.deviceInfo_ = new DeviceInfo(this.dpdb_.getDeviceParams(), config$$1.ADDITIONAL_VIEWERS); + this.viewerSelector_ = new ViewerSelector(config$$1.DEFAULT_VIEWER); + this.viewerSelector_.onChange(this.onViewerChanged_.bind(this)); + this.deviceInfo_.setViewer(this.viewerSelector_.getCurrentViewer()); + if (!this.config.ROTATE_INSTRUCTIONS_DISABLED) { + this.rotateInstructions_ = new RotateInstructions(); + } + if (isIOS()) { + window.addEventListener('resize', this.onResize_.bind(this)); + } + } + CardboardVRDisplay.prototype = Object.create(VRDisplay.prototype); + CardboardVRDisplay.prototype._getPose = function () { + return { + position: null, + orientation: this.poseSensor_.getOrientation(), + linearVelocity: null, + linearAcceleration: null, + angularVelocity: null, + angularAcceleration: null + }; + }; + CardboardVRDisplay.prototype._resetPose = function () { + if (this.poseSensor_.resetPose) { + this.poseSensor_.resetPose(); + } + }; + CardboardVRDisplay.prototype._getFieldOfView = function (whichEye) { + var fieldOfView; + if (whichEye == Eye.LEFT) { + fieldOfView = this.deviceInfo_.getFieldOfViewLeftEye(); + } else if (whichEye == Eye.RIGHT) { + fieldOfView = this.deviceInfo_.getFieldOfViewRightEye(); + } else { + console.error('Invalid eye provided: %s', whichEye); + return null; + } + return fieldOfView; + }; + CardboardVRDisplay.prototype._getEyeOffset = function (whichEye) { + var offset; + if (whichEye == Eye.LEFT) { + offset = [-this.deviceInfo_.viewer.interLensDistance * 0.5, 0.0, 0.0]; + } else if (whichEye == Eye.RIGHT) { + offset = [this.deviceInfo_.viewer.interLensDistance * 0.5, 0.0, 0.0]; + } else { + console.error('Invalid eye provided: %s', whichEye); + return null; + } + return offset; + }; + CardboardVRDisplay.prototype.getEyeParameters = function (whichEye) { + var offset = this._getEyeOffset(whichEye); + var fieldOfView = this._getFieldOfView(whichEye); + var eyeParams = { + offset: offset, + renderWidth: this.deviceInfo_.device.width * 0.5 * this.bufferScale_, + renderHeight: this.deviceInfo_.device.height * this.bufferScale_ + }; + Object.defineProperty(eyeParams, 'fieldOfView', { + enumerable: true, + get: function get() { + deprecateWarning('VRFieldOfView', 'VRFrameData\'s projection matrices'); + return fieldOfView; + } + }); + return eyeParams; + }; + CardboardVRDisplay.prototype.onDeviceParamsUpdated_ = function (newParams) { + if (this.config.DEBUG) { + console.log('DPDB reported that device params were updated.'); + } + this.deviceInfo_.updateDeviceParams(newParams); + if (this.distorter_) { + this.distorter_.updateDeviceInfo(this.deviceInfo_); + } + }; + CardboardVRDisplay.prototype.updateBounds_ = function () { + if (this.layer_ && this.distorter_ && (this.layer_.leftBounds || this.layer_.rightBounds)) { + this.distorter_.setTextureBounds(this.layer_.leftBounds, this.layer_.rightBounds); + } + }; + CardboardVRDisplay.prototype.beginPresent_ = function () { + var gl = this.layer_.source.getContext('webgl'); + if (!gl) gl = this.layer_.source.getContext('experimental-webgl'); + if (!gl) gl = this.layer_.source.getContext('webgl2'); + if (!gl) return; + if (this.layer_.predistorted) { + if (!this.config.CARDBOARD_UI_DISABLED) { + gl.canvas.width = getScreenWidth() * this.bufferScale_; + gl.canvas.height = getScreenHeight() * this.bufferScale_; + this.cardboardUI_ = new CardboardUI(gl); + } + } else { + if (!this.config.CARDBOARD_UI_DISABLED) { + this.cardboardUI_ = new CardboardUI(gl); + } + this.distorter_ = new CardboardDistorter(gl, this.cardboardUI_, this.config.BUFFER_SCALE, this.config.DIRTY_SUBMIT_FRAME_BINDINGS); + this.distorter_.updateDeviceInfo(this.deviceInfo_); + } + if (this.cardboardUI_) { + this.cardboardUI_.listen(function (e) { + this.viewerSelector_.show(this.layer_.source.parentElement); + e.stopPropagation(); + e.preventDefault(); + }.bind(this), function (e) { + this.exitPresent(); + e.stopPropagation(); + e.preventDefault(); + }.bind(this)); + } + if (this.rotateInstructions_) { + if (isLandscapeMode() && isMobile()) { + this.rotateInstructions_.showTemporarily(3000, this.layer_.source.parentElement); + } else { + this.rotateInstructions_.update(); + } + } + this.orientationHandler = this.onOrientationChange_.bind(this); + window.addEventListener('orientationchange', this.orientationHandler); + this.vrdisplaypresentchangeHandler = this.updateBounds_.bind(this); + window.addEventListener('vrdisplaypresentchange', this.vrdisplaypresentchangeHandler); + this.fireVRDisplayDeviceParamsChange_(); + }; + CardboardVRDisplay.prototype.endPresent_ = function () { + if (this.distorter_) { + this.distorter_.destroy(); + this.distorter_ = null; + } + if (this.cardboardUI_) { + this.cardboardUI_.destroy(); + this.cardboardUI_ = null; + } + if (this.rotateInstructions_) { + this.rotateInstructions_.hide(); + } + this.viewerSelector_.hide(); + window.removeEventListener('orientationchange', this.orientationHandler); + window.removeEventListener('vrdisplaypresentchange', this.vrdisplaypresentchangeHandler); + }; + CardboardVRDisplay.prototype.updatePresent_ = function () { + this.endPresent_(); + this.beginPresent_(); + }; + CardboardVRDisplay.prototype.submitFrame = function (pose) { + if (this.distorter_) { + this.updateBounds_(); + this.distorter_.submitFrame(); + } else if (this.cardboardUI_ && this.layer_) { + var canvas = this.layer_.source.getContext('webgl').canvas; + if (canvas.width != this.lastWidth || canvas.height != this.lastHeight) { + this.cardboardUI_.onResize(); + } + this.lastWidth = canvas.width; + this.lastHeight = canvas.height; + this.cardboardUI_.render(); + } + }; + CardboardVRDisplay.prototype.onOrientationChange_ = function (e) { + this.viewerSelector_.hide(); + if (this.rotateInstructions_) { + this.rotateInstructions_.update(); + } + this.onResize_(); + }; + CardboardVRDisplay.prototype.onResize_ = function (e) { + if (this.layer_) { + var gl = this.layer_.source.getContext('webgl'); + var cssProperties = ['position: absolute', 'top: 0', 'left: 0', + 'width: 100vw', 'height: 100vh', 'border: 0', 'margin: 0', + 'padding: 0px', 'box-sizing: content-box']; + gl.canvas.setAttribute('style', cssProperties.join('; ') + ';'); + safariCssSizeWorkaround(gl.canvas); + } + }; + CardboardVRDisplay.prototype.onViewerChanged_ = function (viewer) { + this.deviceInfo_.setViewer(viewer); + if (this.distorter_) { + this.distorter_.updateDeviceInfo(this.deviceInfo_); + } + this.fireVRDisplayDeviceParamsChange_(); + }; + CardboardVRDisplay.prototype.fireVRDisplayDeviceParamsChange_ = function () { + var event = new CustomEvent('vrdisplaydeviceparamschange', { + detail: { + vrdisplay: this, + deviceInfo: this.deviceInfo_ + } + }); + window.dispatchEvent(event); + }; + CardboardVRDisplay.VRFrameData = VRFrameData; + CardboardVRDisplay.VRDisplay = VRDisplay; + return CardboardVRDisplay; + }))); + }); + var CardboardVRDisplay = unwrapExports(cardboardVrDisplay); + + var version = "0.10.6"; + + var DefaultConfig = { + ADDITIONAL_VIEWERS: [], + DEFAULT_VIEWER: '', + PROVIDE_MOBILE_VRDISPLAY: true, + GET_VR_DISPLAYS_TIMEOUT: 1000, + MOBILE_WAKE_LOCK: true, + DEBUG: false, + DPDB_URL: 'https://dpdb.webvr.rocks/dpdb.json', + K_FILTER: 0.98, + PREDICTION_TIME_S: 0.040, + TOUCH_PANNER_DISABLED: true, + CARDBOARD_UI_DISABLED: false, + ROTATE_INSTRUCTIONS_DISABLED: false, + YAW_ONLY: false, + BUFFER_SCALE: 0.5, + DIRTY_SUBMIT_FRAME_BINDINGS: false + }; + + function WebVRPolyfill(config) { + this.config = extend(extend({}, DefaultConfig), config); + this.polyfillDisplays = []; + this.enabled = false; + this.hasNative = 'getVRDisplays' in navigator; + this.native = {}; + this.native.getVRDisplays = navigator.getVRDisplays; + this.native.VRFrameData = window.VRFrameData; + this.native.VRDisplay = window.VRDisplay; + if (!this.hasNative || this.config.PROVIDE_MOBILE_VRDISPLAY && isMobile()) { + this.enable(); + this.getVRDisplays().then(function (displays) { + if (displays && displays[0] && displays[0].fireVRDisplayConnect_) { + displays[0].fireVRDisplayConnect_(); + } + }); + } + } + WebVRPolyfill.prototype.getPolyfillDisplays = function () { + if (this._polyfillDisplaysPopulated) { + return this.polyfillDisplays; + } + if (isMobile()) { + var vrDisplay = new CardboardVRDisplay({ + ADDITIONAL_VIEWERS: this.config.ADDITIONAL_VIEWERS, + DEFAULT_VIEWER: this.config.DEFAULT_VIEWER, + MOBILE_WAKE_LOCK: this.config.MOBILE_WAKE_LOCK, + DEBUG: this.config.DEBUG, + DPDB_URL: this.config.DPDB_URL, + CARDBOARD_UI_DISABLED: this.config.CARDBOARD_UI_DISABLED, + K_FILTER: this.config.K_FILTER, + PREDICTION_TIME_S: this.config.PREDICTION_TIME_S, + TOUCH_PANNER_DISABLED: this.config.TOUCH_PANNER_DISABLED, + ROTATE_INSTRUCTIONS_DISABLED: this.config.ROTATE_INSTRUCTIONS_DISABLED, + YAW_ONLY: this.config.YAW_ONLY, + BUFFER_SCALE: this.config.BUFFER_SCALE, + DIRTY_SUBMIT_FRAME_BINDINGS: this.config.DIRTY_SUBMIT_FRAME_BINDINGS + }); + this.polyfillDisplays.push(vrDisplay); + } + this._polyfillDisplaysPopulated = true; + return this.polyfillDisplays; + }; + WebVRPolyfill.prototype.enable = function () { + this.enabled = true; + if (this.hasNative && this.native.VRFrameData) { + var NativeVRFrameData = this.native.VRFrameData; + var nativeFrameData = new this.native.VRFrameData(); + var nativeGetFrameData = this.native.VRDisplay.prototype.getFrameData; + window.VRDisplay.prototype.getFrameData = function (frameData) { + if (frameData instanceof NativeVRFrameData) { + nativeGetFrameData.call(this, frameData); + return; + } + nativeGetFrameData.call(this, nativeFrameData); + frameData.pose = nativeFrameData.pose; + copyArray(nativeFrameData.leftProjectionMatrix, frameData.leftProjectionMatrix); + copyArray(nativeFrameData.rightProjectionMatrix, frameData.rightProjectionMatrix); + copyArray(nativeFrameData.leftViewMatrix, frameData.leftViewMatrix); + copyArray(nativeFrameData.rightViewMatrix, frameData.rightViewMatrix); + }; + } + navigator.getVRDisplays = this.getVRDisplays.bind(this); + window.VRDisplay = CardboardVRDisplay.VRDisplay; + window.VRFrameData = CardboardVRDisplay.VRFrameData; + }; + WebVRPolyfill.prototype.getVRDisplays = function () { + var _this = this; + var config = this.config; + if (!this.hasNative) { + return Promise.resolve(this.getPolyfillDisplays()); + } + var timeoutId; + var vrDisplaysNative = this.native.getVRDisplays.call(navigator); + var timeoutPromise = new Promise(function (resolve) { + timeoutId = setTimeout(function () { + console.warn('Native WebVR implementation detected, but `getVRDisplays()` failed to resolve. Falling back to polyfill.'); + resolve([]); + }, config.GET_VR_DISPLAYS_TIMEOUT); + }); + return race([vrDisplaysNative, timeoutPromise]).then(function (nativeDisplays) { + clearTimeout(timeoutId); + return nativeDisplays.length > 0 ? nativeDisplays : _this.getPolyfillDisplays(); + }); + }; + WebVRPolyfill.version = version; + WebVRPolyfill.VRFrameData = CardboardVRDisplay.VRFrameData; + WebVRPolyfill.VRDisplay = CardboardVRDisplay.VRDisplay; + + + var webvrPolyfill = Object.freeze({ + default: WebVRPolyfill + }); + + var require$$0 = ( webvrPolyfill && WebVRPolyfill ) || webvrPolyfill; + + if (typeof commonjsGlobal$1 !== 'undefined' && commonjsGlobal$1.window) { + if (!commonjsGlobal$1.document) { + commonjsGlobal$1.document = commonjsGlobal$1.window.document; + } + if (!commonjsGlobal$1.navigator) { + commonjsGlobal$1.navigator = commonjsGlobal$1.window.navigator; + } + } + var src = require$$0; + + return src; + + }))); + }); + + var WebVRPolyfill = unwrapExports(webvrPolyfill); + + // Polyfills + + if ( Number.EPSILON === undefined ) { + + Number.EPSILON = Math.pow( 2, - 52 ); + + } + + if ( Number.isInteger === undefined ) { + + // Missing in IE + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isInteger + + Number.isInteger = function ( value ) { + + return typeof value === 'number' && isFinite( value ) && Math.floor( value ) === value; + + }; + + } + + // + + if ( Math.sign === undefined ) { + + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/sign + + Math.sign = function ( x ) { + + return ( x < 0 ) ? - 1 : ( x > 0 ) ? 1 : + x; + + }; + + } + + if ( 'name' in Function.prototype === false ) { + + // Missing in IE + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/name + + Object.defineProperty( Function.prototype, 'name', { + + get: function () { + + return this.toString().match( /^\s*function\s*([^\(\s]*)/ )[ 1 ]; + + } + + } ); + + } + + if ( Object.assign === undefined ) { + + // Missing in IE + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/assign + + ( function () { + + Object.assign = function ( target ) { + + if ( target === undefined || target === null ) { + + throw new TypeError( 'Cannot convert undefined or null to object' ); + + } + + var output = Object( target ); + + for ( var index = 1; index < arguments.length; index ++ ) { + + var source = arguments[ index ]; + + if ( source !== undefined && source !== null ) { + + for ( var nextKey in source ) { + + if ( Object.prototype.hasOwnProperty.call( source, nextKey ) ) { + + output[ nextKey ] = source[ nextKey ]; + + } + + } + + } + + } + + return output; + + }; + + } )(); + + } + + /** + * https://github.com/mrdoob/eventdispatcher.js/ + */ + + function EventDispatcher() {} + + Object.assign( EventDispatcher.prototype, { + + addEventListener: function ( type, listener ) { + + if ( this._listeners === undefined ) this._listeners = {}; + + var listeners = this._listeners; + + if ( listeners[ type ] === undefined ) { + + listeners[ type ] = []; + + } + + if ( listeners[ type ].indexOf( listener ) === - 1 ) { + + listeners[ type ].push( listener ); + + } + + }, + + hasEventListener: function ( type, listener ) { + + if ( this._listeners === undefined ) return false; + + var listeners = this._listeners; + + return listeners[ type ] !== undefined && listeners[ type ].indexOf( listener ) !== - 1; + + }, + + removeEventListener: function ( type, listener ) { + + if ( this._listeners === undefined ) return; + + var listeners = this._listeners; + var listenerArray = listeners[ type ]; + + if ( listenerArray !== undefined ) { + + var index = listenerArray.indexOf( listener ); + + if ( index !== - 1 ) { + + listenerArray.splice( index, 1 ); + + } + + } + + }, + + dispatchEvent: function ( event ) { + + if ( this._listeners === undefined ) return; + + var listeners = this._listeners; + var listenerArray = listeners[ event.type ]; + + if ( listenerArray !== undefined ) { + + event.target = this; + + var array = listenerArray.slice( 0 ); + + for ( var i = 0, l = array.length; i < l; i ++ ) { + + array[ i ].call( this, event ); + + } + + } + + } + + } ); + + var REVISION = '93'; + var MOUSE = { LEFT: 0, MIDDLE: 1, RIGHT: 2 }; + var CullFaceNone = 0; + var CullFaceBack = 1; + var CullFaceFront = 2; + var PCFShadowMap = 1; + var PCFSoftShadowMap = 2; + var FrontSide = 0; + var BackSide = 1; + var DoubleSide = 2; + var FlatShading = 1; + var NoColors = 0; + var FaceColors = 1; + var VertexColors = 2; + var NoBlending = 0; + var NormalBlending = 1; + var AdditiveBlending = 2; + var SubtractiveBlending = 3; + var MultiplyBlending = 4; + var CustomBlending = 5; + var AddEquation = 100; + var SubtractEquation = 101; + var ReverseSubtractEquation = 102; + var MinEquation = 103; + var MaxEquation = 104; + var ZeroFactor = 200; + var OneFactor = 201; + var SrcColorFactor = 202; + var OneMinusSrcColorFactor = 203; + var SrcAlphaFactor = 204; + var OneMinusSrcAlphaFactor = 205; + var DstAlphaFactor = 206; + var OneMinusDstAlphaFactor = 207; + var DstColorFactor = 208; + var OneMinusDstColorFactor = 209; + var SrcAlphaSaturateFactor = 210; + var NeverDepth = 0; + var AlwaysDepth = 1; + var LessDepth = 2; + var LessEqualDepth = 3; + var EqualDepth = 4; + var GreaterEqualDepth = 5; + var GreaterDepth = 6; + var NotEqualDepth = 7; + var MultiplyOperation = 0; + var MixOperation = 1; + var AddOperation = 2; + var NoToneMapping = 0; + var LinearToneMapping = 1; + var ReinhardToneMapping = 2; + var Uncharted2ToneMapping = 3; + var CineonToneMapping = 4; + var UVMapping = 300; + var CubeReflectionMapping = 301; + var CubeRefractionMapping = 302; + var EquirectangularReflectionMapping = 303; + var EquirectangularRefractionMapping = 304; + var SphericalReflectionMapping = 305; + var CubeUVReflectionMapping = 306; + var CubeUVRefractionMapping = 307; + var RepeatWrapping = 1000; + var ClampToEdgeWrapping = 1001; + var MirroredRepeatWrapping = 1002; + var NearestFilter = 1003; + var NearestMipMapNearestFilter = 1004; + var NearestMipMapLinearFilter = 1005; + var LinearFilter = 1006; + var LinearMipMapNearestFilter = 1007; + var LinearMipMapLinearFilter = 1008; + var UnsignedByteType = 1009; + var ByteType = 1010; + var ShortType = 1011; + var UnsignedShortType = 1012; + var IntType = 1013; + var UnsignedIntType = 1014; + var FloatType = 1015; + var HalfFloatType = 1016; + var UnsignedShort4444Type = 1017; + var UnsignedShort5551Type = 1018; + var UnsignedShort565Type = 1019; + var UnsignedInt248Type = 1020; + var AlphaFormat = 1021; + var RGBFormat = 1022; + var RGBAFormat = 1023; + var LuminanceFormat = 1024; + var LuminanceAlphaFormat = 1025; + var DepthFormat = 1026; + var DepthStencilFormat = 1027; + var RGB_S3TC_DXT1_Format = 33776; + var RGBA_S3TC_DXT1_Format = 33777; + var RGBA_S3TC_DXT3_Format = 33778; + var RGBA_S3TC_DXT5_Format = 33779; + var RGB_PVRTC_4BPPV1_Format = 35840; + var RGB_PVRTC_2BPPV1_Format = 35841; + var RGBA_PVRTC_4BPPV1_Format = 35842; + var RGBA_PVRTC_2BPPV1_Format = 35843; + var RGB_ETC1_Format = 36196; + var RGBA_ASTC_4x4_Format = 37808; + var RGBA_ASTC_5x4_Format = 37809; + var RGBA_ASTC_5x5_Format = 37810; + var RGBA_ASTC_6x5_Format = 37811; + var RGBA_ASTC_6x6_Format = 37812; + var RGBA_ASTC_8x5_Format = 37813; + var RGBA_ASTC_8x6_Format = 37814; + var RGBA_ASTC_8x8_Format = 37815; + var RGBA_ASTC_10x5_Format = 37816; + var RGBA_ASTC_10x6_Format = 37817; + var RGBA_ASTC_10x8_Format = 37818; + var RGBA_ASTC_10x10_Format = 37819; + var RGBA_ASTC_12x10_Format = 37820; + var RGBA_ASTC_12x12_Format = 37821; + var LoopOnce = 2200; + var LoopRepeat = 2201; + var LoopPingPong = 2202; + var InterpolateDiscrete = 2300; + var InterpolateLinear = 2301; + var InterpolateSmooth = 2302; + var ZeroCurvatureEnding = 2400; + var ZeroSlopeEnding = 2401; + var WrapAroundEnding = 2402; + var TrianglesDrawMode = 0; + var TriangleStripDrawMode = 1; + var TriangleFanDrawMode = 2; + var LinearEncoding = 3000; + var sRGBEncoding = 3001; + var GammaEncoding = 3007; + var RGBEEncoding = 3002; + var RGBM7Encoding = 3004; + var RGBM16Encoding = 3005; + var RGBDEncoding = 3006; + var BasicDepthPacking = 3200; + var RGBADepthPacking = 3201; + + /** + * @author alteredq / http://alteredqualia.com/ + * @author mrdoob / http://mrdoob.com/ + */ + + var _Math = { + + DEG2RAD: Math.PI / 180, + RAD2DEG: 180 / Math.PI, + + generateUUID: ( function () { + + // http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript/21963136#21963136 + + var lut = []; + + for ( var i = 0; i < 256; i ++ ) { + + lut[ i ] = ( i < 16 ? '0' : '' ) + ( i ).toString( 16 ); + + } + + return function generateUUID() { + + var d0 = Math.random() * 0xffffffff | 0; + var d1 = Math.random() * 0xffffffff | 0; + var d2 = Math.random() * 0xffffffff | 0; + var d3 = Math.random() * 0xffffffff | 0; + var uuid = lut[ d0 & 0xff ] + lut[ d0 >> 8 & 0xff ] + lut[ d0 >> 16 & 0xff ] + lut[ d0 >> 24 & 0xff ] + '-' + + lut[ d1 & 0xff ] + lut[ d1 >> 8 & 0xff ] + '-' + lut[ d1 >> 16 & 0x0f | 0x40 ] + lut[ d1 >> 24 & 0xff ] + '-' + + lut[ d2 & 0x3f | 0x80 ] + lut[ d2 >> 8 & 0xff ] + '-' + lut[ d2 >> 16 & 0xff ] + lut[ d2 >> 24 & 0xff ] + + lut[ d3 & 0xff ] + lut[ d3 >> 8 & 0xff ] + lut[ d3 >> 16 & 0xff ] + lut[ d3 >> 24 & 0xff ]; + + // .toUpperCase() here flattens concatenated strings to save heap memory space. + return uuid.toUpperCase(); + + }; + + } )(), + + clamp: function ( value, min, max ) { + + return Math.max( min, Math.min( max, value ) ); + + }, + + // compute euclidian modulo of m % n + // https://en.wikipedia.org/wiki/Modulo_operation + + euclideanModulo: function ( n, m ) { + + return ( ( n % m ) + m ) % m; + + }, + + // Linear mapping from range to range + + mapLinear: function ( x, a1, a2, b1, b2 ) { + + return b1 + ( x - a1 ) * ( b2 - b1 ) / ( a2 - a1 ); + + }, + + // https://en.wikipedia.org/wiki/Linear_interpolation + + lerp: function ( x, y, t ) { + + return ( 1 - t ) * x + t * y; + + }, + + // http://en.wikipedia.org/wiki/Smoothstep + + smoothstep: function ( x, min, max ) { + + if ( x <= min ) return 0; + if ( x >= max ) return 1; + + x = ( x - min ) / ( max - min ); + + return x * x * ( 3 - 2 * x ); + + }, + + smootherstep: function ( x, min, max ) { + + if ( x <= min ) return 0; + if ( x >= max ) return 1; + + x = ( x - min ) / ( max - min ); + + return x * x * x * ( x * ( x * 6 - 15 ) + 10 ); + + }, + + // Random integer from interval + + randInt: function ( low, high ) { + + return low + Math.floor( Math.random() * ( high - low + 1 ) ); + + }, + + // Random float from interval + + randFloat: function ( low, high ) { + + return low + Math.random() * ( high - low ); + + }, + + // Random float from <-range/2, range/2> interval + + randFloatSpread: function ( range ) { + + return range * ( 0.5 - Math.random() ); + + }, + + degToRad: function ( degrees ) { + + return degrees * _Math.DEG2RAD; + + }, + + radToDeg: function ( radians ) { + + return radians * _Math.RAD2DEG; + + }, + + isPowerOfTwo: function ( value ) { + + return ( value & ( value - 1 ) ) === 0 && value !== 0; + + }, + + ceilPowerOfTwo: function ( value ) { + + return Math.pow( 2, Math.ceil( Math.log( value ) / Math.LN2 ) ); + + }, + + floorPowerOfTwo: function ( value ) { + + return Math.pow( 2, Math.floor( Math.log( value ) / Math.LN2 ) ); + + } + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author philogb / http://blog.thejit.org/ + * @author egraether / http://egraether.com/ + * @author zz85 / http://www.lab4games.net/zz85/blog + */ + + function Vector2( x, y ) { + + this.x = x || 0; + this.y = y || 0; + + } + + Object.defineProperties( Vector2.prototype, { + + "width": { + + get: function () { + + return this.x; + + }, + + set: function ( value ) { + + this.x = value; + + } + + }, + + "height": { + + get: function () { + + return this.y; + + }, + + set: function ( value ) { + + this.y = value; + + } + + } + + } ); + + Object.assign( Vector2.prototype, { + + isVector2: true, + + set: function ( x, y ) { + + this.x = x; + this.y = y; + + return this; + + }, + + setScalar: function ( scalar ) { + + this.x = scalar; + this.y = scalar; + + return this; + + }, + + setX: function ( x ) { + + this.x = x; + + return this; + + }, + + setY: function ( y ) { + + this.y = y; + + return this; + + }, + + setComponent: function ( index, value ) { + + switch ( index ) { + + case 0: this.x = value; break; + case 1: this.y = value; break; + default: throw new Error( 'index is out of range: ' + index ); + + } + + return this; + + }, + + getComponent: function ( index ) { + + switch ( index ) { + + case 0: return this.x; + case 1: return this.y; + default: throw new Error( 'index is out of range: ' + index ); + + } + + }, + + clone: function () { + + return new this.constructor( this.x, this.y ); + + }, + + copy: function ( v ) { + + this.x = v.x; + this.y = v.y; + + return this; + + }, + + add: function ( v, w ) { + + if ( w !== undefined ) { + + console.warn( 'THREE.Vector2: .add() now only accepts one argument. Use .addVectors( a, b ) instead.' ); + return this.addVectors( v, w ); + + } + + this.x += v.x; + this.y += v.y; + + return this; + + }, + + addScalar: function ( s ) { + + this.x += s; + this.y += s; + + return this; + + }, + + addVectors: function ( a, b ) { + + this.x = a.x + b.x; + this.y = a.y + b.y; + + return this; + + }, + + addScaledVector: function ( v, s ) { + + this.x += v.x * s; + this.y += v.y * s; + + return this; + + }, + + sub: function ( v, w ) { + + if ( w !== undefined ) { + + console.warn( 'THREE.Vector2: .sub() now only accepts one argument. Use .subVectors( a, b ) instead.' ); + return this.subVectors( v, w ); + + } + + this.x -= v.x; + this.y -= v.y; + + return this; + + }, + + subScalar: function ( s ) { + + this.x -= s; + this.y -= s; + + return this; + + }, + + subVectors: function ( a, b ) { + + this.x = a.x - b.x; + this.y = a.y - b.y; + + return this; + + }, + + multiply: function ( v ) { + + this.x *= v.x; + this.y *= v.y; + + return this; + + }, + + multiplyScalar: function ( scalar ) { + + this.x *= scalar; + this.y *= scalar; + + return this; + + }, + + divide: function ( v ) { + + this.x /= v.x; + this.y /= v.y; + + return this; + + }, + + divideScalar: function ( scalar ) { + + return this.multiplyScalar( 1 / scalar ); + + }, + + applyMatrix3: function ( m ) { + + var x = this.x, y = this.y; + var e = m.elements; + + this.x = e[ 0 ] * x + e[ 3 ] * y + e[ 6 ]; + this.y = e[ 1 ] * x + e[ 4 ] * y + e[ 7 ]; + + return this; + + }, + + min: function ( v ) { + + this.x = Math.min( this.x, v.x ); + this.y = Math.min( this.y, v.y ); + + return this; + + }, + + max: function ( v ) { + + this.x = Math.max( this.x, v.x ); + this.y = Math.max( this.y, v.y ); + + return this; + + }, + + clamp: function ( min, max ) { + + // assumes min < max, componentwise + + this.x = Math.max( min.x, Math.min( max.x, this.x ) ); + this.y = Math.max( min.y, Math.min( max.y, this.y ) ); + + return this; + + }, + + clampScalar: function () { + + var min = new Vector2(); + var max = new Vector2(); + + return function clampScalar( minVal, maxVal ) { + + min.set( minVal, minVal ); + max.set( maxVal, maxVal ); + + return this.clamp( min, max ); + + }; + + }(), + + clampLength: function ( min, max ) { + + var length = this.length(); + + return this.divideScalar( length || 1 ).multiplyScalar( Math.max( min, Math.min( max, length ) ) ); + + }, + + floor: function () { + + this.x = Math.floor( this.x ); + this.y = Math.floor( this.y ); + + return this; + + }, + + ceil: function () { + + this.x = Math.ceil( this.x ); + this.y = Math.ceil( this.y ); + + return this; + + }, + + round: function () { + + this.x = Math.round( this.x ); + this.y = Math.round( this.y ); + + return this; + + }, + + roundToZero: function () { + + this.x = ( this.x < 0 ) ? Math.ceil( this.x ) : Math.floor( this.x ); + this.y = ( this.y < 0 ) ? Math.ceil( this.y ) : Math.floor( this.y ); + + return this; + + }, + + negate: function () { + + this.x = - this.x; + this.y = - this.y; + + return this; + + }, + + dot: function ( v ) { + + return this.x * v.x + this.y * v.y; + + }, + + lengthSq: function () { + + return this.x * this.x + this.y * this.y; + + }, + + length: function () { + + return Math.sqrt( this.x * this.x + this.y * this.y ); + + }, + + manhattanLength: function () { + + return Math.abs( this.x ) + Math.abs( this.y ); + + }, + + normalize: function () { + + return this.divideScalar( this.length() || 1 ); + + }, + + angle: function () { + + // computes the angle in radians with respect to the positive x-axis + + var angle = Math.atan2( this.y, this.x ); + + if ( angle < 0 ) angle += 2 * Math.PI; + + return angle; + + }, + + distanceTo: function ( v ) { + + return Math.sqrt( this.distanceToSquared( v ) ); + + }, + + distanceToSquared: function ( v ) { + + var dx = this.x - v.x, dy = this.y - v.y; + return dx * dx + dy * dy; + + }, + + manhattanDistanceTo: function ( v ) { + + return Math.abs( this.x - v.x ) + Math.abs( this.y - v.y ); + + }, + + setLength: function ( length ) { + + return this.normalize().multiplyScalar( length ); + + }, + + lerp: function ( v, alpha ) { + + this.x += ( v.x - this.x ) * alpha; + this.y += ( v.y - this.y ) * alpha; + + return this; + + }, + + lerpVectors: function ( v1, v2, alpha ) { + + return this.subVectors( v2, v1 ).multiplyScalar( alpha ).add( v1 ); + + }, + + equals: function ( v ) { + + return ( ( v.x === this.x ) && ( v.y === this.y ) ); + + }, + + fromArray: function ( array, offset ) { + + if ( offset === undefined ) offset = 0; + + this.x = array[ offset ]; + this.y = array[ offset + 1 ]; + + return this; + + }, + + toArray: function ( array, offset ) { + + if ( array === undefined ) array = []; + if ( offset === undefined ) offset = 0; + + array[ offset ] = this.x; + array[ offset + 1 ] = this.y; + + return array; + + }, + + fromBufferAttribute: function ( attribute, index, offset ) { + + if ( offset !== undefined ) { + + console.warn( 'THREE.Vector2: offset has been removed from .fromBufferAttribute().' ); + + } + + this.x = attribute.getX( index ); + this.y = attribute.getY( index ); + + return this; + + }, + + rotateAround: function ( center, angle ) { + + var c = Math.cos( angle ), s = Math.sin( angle ); + + var x = this.x - center.x; + var y = this.y - center.y; + + this.x = x * c - y * s + center.x; + this.y = x * s + y * c + center.y; + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author supereggbert / http://www.paulbrunt.co.uk/ + * @author philogb / http://blog.thejit.org/ + * @author jordi_ros / http://plattsoft.com + * @author D1plo1d / http://github.com/D1plo1d + * @author alteredq / http://alteredqualia.com/ + * @author mikael emtinger / http://gomo.se/ + * @author timknip / http://www.floorplanner.com/ + * @author bhouston / http://clara.io + * @author WestLangley / http://github.com/WestLangley + */ + + function Matrix4() { + + this.elements = [ + + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 + + ]; + + if ( arguments.length > 0 ) { + + console.error( 'THREE.Matrix4: the constructor no longer reads arguments. use .set() instead.' ); + + } + + } + + Object.assign( Matrix4.prototype, { + + isMatrix4: true, + + set: function ( n11, n12, n13, n14, n21, n22, n23, n24, n31, n32, n33, n34, n41, n42, n43, n44 ) { + + var te = this.elements; + + te[ 0 ] = n11; te[ 4 ] = n12; te[ 8 ] = n13; te[ 12 ] = n14; + te[ 1 ] = n21; te[ 5 ] = n22; te[ 9 ] = n23; te[ 13 ] = n24; + te[ 2 ] = n31; te[ 6 ] = n32; te[ 10 ] = n33; te[ 14 ] = n34; + te[ 3 ] = n41; te[ 7 ] = n42; te[ 11 ] = n43; te[ 15 ] = n44; + + return this; + + }, + + identity: function () { + + this.set( + + 1, 0, 0, 0, + 0, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 + + ); + + return this; + + }, + + clone: function () { + + return new Matrix4().fromArray( this.elements ); + + }, + + copy: function ( m ) { + + var te = this.elements; + var me = m.elements; + + te[ 0 ] = me[ 0 ]; te[ 1 ] = me[ 1 ]; te[ 2 ] = me[ 2 ]; te[ 3 ] = me[ 3 ]; + te[ 4 ] = me[ 4 ]; te[ 5 ] = me[ 5 ]; te[ 6 ] = me[ 6 ]; te[ 7 ] = me[ 7 ]; + te[ 8 ] = me[ 8 ]; te[ 9 ] = me[ 9 ]; te[ 10 ] = me[ 10 ]; te[ 11 ] = me[ 11 ]; + te[ 12 ] = me[ 12 ]; te[ 13 ] = me[ 13 ]; te[ 14 ] = me[ 14 ]; te[ 15 ] = me[ 15 ]; + + return this; + + }, + + copyPosition: function ( m ) { + + var te = this.elements, me = m.elements; + + te[ 12 ] = me[ 12 ]; + te[ 13 ] = me[ 13 ]; + te[ 14 ] = me[ 14 ]; + + return this; + + }, + + extractBasis: function ( xAxis, yAxis, zAxis ) { + + xAxis.setFromMatrixColumn( this, 0 ); + yAxis.setFromMatrixColumn( this, 1 ); + zAxis.setFromMatrixColumn( this, 2 ); + + return this; + + }, + + makeBasis: function ( xAxis, yAxis, zAxis ) { + + this.set( + xAxis.x, yAxis.x, zAxis.x, 0, + xAxis.y, yAxis.y, zAxis.y, 0, + xAxis.z, yAxis.z, zAxis.z, 0, + 0, 0, 0, 1 + ); + + return this; + + }, + + extractRotation: function () { + + var v1 = new Vector3(); + + return function extractRotation( m ) { + + // this method does not support reflection matrices + + var te = this.elements; + var me = m.elements; + + var scaleX = 1 / v1.setFromMatrixColumn( m, 0 ).length(); + var scaleY = 1 / v1.setFromMatrixColumn( m, 1 ).length(); + var scaleZ = 1 / v1.setFromMatrixColumn( m, 2 ).length(); + + te[ 0 ] = me[ 0 ] * scaleX; + te[ 1 ] = me[ 1 ] * scaleX; + te[ 2 ] = me[ 2 ] * scaleX; + te[ 3 ] = 0; + + te[ 4 ] = me[ 4 ] * scaleY; + te[ 5 ] = me[ 5 ] * scaleY; + te[ 6 ] = me[ 6 ] * scaleY; + te[ 7 ] = 0; + + te[ 8 ] = me[ 8 ] * scaleZ; + te[ 9 ] = me[ 9 ] * scaleZ; + te[ 10 ] = me[ 10 ] * scaleZ; + te[ 11 ] = 0; + + te[ 12 ] = 0; + te[ 13 ] = 0; + te[ 14 ] = 0; + te[ 15 ] = 1; + + return this; + + }; + + }(), + + makeRotationFromEuler: function ( euler ) { + + if ( ! ( euler && euler.isEuler ) ) { + + console.error( 'THREE.Matrix4: .makeRotationFromEuler() now expects a Euler rotation rather than a Vector3 and order.' ); + + } + + var te = this.elements; + + var x = euler.x, y = euler.y, z = euler.z; + var a = Math.cos( x ), b = Math.sin( x ); + var c = Math.cos( y ), d = Math.sin( y ); + var e = Math.cos( z ), f = Math.sin( z ); + + if ( euler.order === 'XYZ' ) { + + var ae = a * e, af = a * f, be = b * e, bf = b * f; + + te[ 0 ] = c * e; + te[ 4 ] = - c * f; + te[ 8 ] = d; + + te[ 1 ] = af + be * d; + te[ 5 ] = ae - bf * d; + te[ 9 ] = - b * c; + + te[ 2 ] = bf - ae * d; + te[ 6 ] = be + af * d; + te[ 10 ] = a * c; + + } else if ( euler.order === 'YXZ' ) { + + var ce = c * e, cf = c * f, de = d * e, df = d * f; + + te[ 0 ] = ce + df * b; + te[ 4 ] = de * b - cf; + te[ 8 ] = a * d; + + te[ 1 ] = a * f; + te[ 5 ] = a * e; + te[ 9 ] = - b; + + te[ 2 ] = cf * b - de; + te[ 6 ] = df + ce * b; + te[ 10 ] = a * c; + + } else if ( euler.order === 'ZXY' ) { + + var ce = c * e, cf = c * f, de = d * e, df = d * f; + + te[ 0 ] = ce - df * b; + te[ 4 ] = - a * f; + te[ 8 ] = de + cf * b; + + te[ 1 ] = cf + de * b; + te[ 5 ] = a * e; + te[ 9 ] = df - ce * b; + + te[ 2 ] = - a * d; + te[ 6 ] = b; + te[ 10 ] = a * c; + + } else if ( euler.order === 'ZYX' ) { + + var ae = a * e, af = a * f, be = b * e, bf = b * f; + + te[ 0 ] = c * e; + te[ 4 ] = be * d - af; + te[ 8 ] = ae * d + bf; + + te[ 1 ] = c * f; + te[ 5 ] = bf * d + ae; + te[ 9 ] = af * d - be; + + te[ 2 ] = - d; + te[ 6 ] = b * c; + te[ 10 ] = a * c; + + } else if ( euler.order === 'YZX' ) { + + var ac = a * c, ad = a * d, bc = b * c, bd = b * d; + + te[ 0 ] = c * e; + te[ 4 ] = bd - ac * f; + te[ 8 ] = bc * f + ad; + + te[ 1 ] = f; + te[ 5 ] = a * e; + te[ 9 ] = - b * e; + + te[ 2 ] = - d * e; + te[ 6 ] = ad * f + bc; + te[ 10 ] = ac - bd * f; + + } else if ( euler.order === 'XZY' ) { + + var ac = a * c, ad = a * d, bc = b * c, bd = b * d; + + te[ 0 ] = c * e; + te[ 4 ] = - f; + te[ 8 ] = d * e; + + te[ 1 ] = ac * f + bd; + te[ 5 ] = a * e; + te[ 9 ] = ad * f - bc; + + te[ 2 ] = bc * f - ad; + te[ 6 ] = b * e; + te[ 10 ] = bd * f + ac; + + } + + // bottom row + te[ 3 ] = 0; + te[ 7 ] = 0; + te[ 11 ] = 0; + + // last column + te[ 12 ] = 0; + te[ 13 ] = 0; + te[ 14 ] = 0; + te[ 15 ] = 1; + + return this; + + }, + + makeRotationFromQuaternion: function () { + + var zero = new Vector3( 0, 0, 0 ); + var one = new Vector3( 1, 1, 1 ); + + return function makeRotationFromQuaternion( q ) { + + return this.compose( zero, q, one ); + + }; + + }(), + + lookAt: function () { + + var x = new Vector3(); + var y = new Vector3(); + var z = new Vector3(); + + return function lookAt( eye, target, up ) { + + var te = this.elements; + + z.subVectors( eye, target ); + + if ( z.lengthSq() === 0 ) { + + // eye and target are in the same position + + z.z = 1; + + } + + z.normalize(); + x.crossVectors( up, z ); + + if ( x.lengthSq() === 0 ) { + + // up and z are parallel + + if ( Math.abs( up.z ) === 1 ) { + + z.x += 0.0001; + + } else { + + z.z += 0.0001; + + } + + z.normalize(); + x.crossVectors( up, z ); + + } + + x.normalize(); + y.crossVectors( z, x ); + + te[ 0 ] = x.x; te[ 4 ] = y.x; te[ 8 ] = z.x; + te[ 1 ] = x.y; te[ 5 ] = y.y; te[ 9 ] = z.y; + te[ 2 ] = x.z; te[ 6 ] = y.z; te[ 10 ] = z.z; + + return this; + + }; + + }(), + + multiply: function ( m, n ) { + + if ( n !== undefined ) { + + console.warn( 'THREE.Matrix4: .multiply() now only accepts one argument. Use .multiplyMatrices( a, b ) instead.' ); + return this.multiplyMatrices( m, n ); + + } + + return this.multiplyMatrices( this, m ); + + }, + + premultiply: function ( m ) { + + return this.multiplyMatrices( m, this ); + + }, + + multiplyMatrices: function ( a, b ) { + + var ae = a.elements; + var be = b.elements; + var te = this.elements; + + var a11 = ae[ 0 ], a12 = ae[ 4 ], a13 = ae[ 8 ], a14 = ae[ 12 ]; + var a21 = ae[ 1 ], a22 = ae[ 5 ], a23 = ae[ 9 ], a24 = ae[ 13 ]; + var a31 = ae[ 2 ], a32 = ae[ 6 ], a33 = ae[ 10 ], a34 = ae[ 14 ]; + var a41 = ae[ 3 ], a42 = ae[ 7 ], a43 = ae[ 11 ], a44 = ae[ 15 ]; + + var b11 = be[ 0 ], b12 = be[ 4 ], b13 = be[ 8 ], b14 = be[ 12 ]; + var b21 = be[ 1 ], b22 = be[ 5 ], b23 = be[ 9 ], b24 = be[ 13 ]; + var b31 = be[ 2 ], b32 = be[ 6 ], b33 = be[ 10 ], b34 = be[ 14 ]; + var b41 = be[ 3 ], b42 = be[ 7 ], b43 = be[ 11 ], b44 = be[ 15 ]; + + te[ 0 ] = a11 * b11 + a12 * b21 + a13 * b31 + a14 * b41; + te[ 4 ] = a11 * b12 + a12 * b22 + a13 * b32 + a14 * b42; + te[ 8 ] = a11 * b13 + a12 * b23 + a13 * b33 + a14 * b43; + te[ 12 ] = a11 * b14 + a12 * b24 + a13 * b34 + a14 * b44; + + te[ 1 ] = a21 * b11 + a22 * b21 + a23 * b31 + a24 * b41; + te[ 5 ] = a21 * b12 + a22 * b22 + a23 * b32 + a24 * b42; + te[ 9 ] = a21 * b13 + a22 * b23 + a23 * b33 + a24 * b43; + te[ 13 ] = a21 * b14 + a22 * b24 + a23 * b34 + a24 * b44; + + te[ 2 ] = a31 * b11 + a32 * b21 + a33 * b31 + a34 * b41; + te[ 6 ] = a31 * b12 + a32 * b22 + a33 * b32 + a34 * b42; + te[ 10 ] = a31 * b13 + a32 * b23 + a33 * b33 + a34 * b43; + te[ 14 ] = a31 * b14 + a32 * b24 + a33 * b34 + a34 * b44; + + te[ 3 ] = a41 * b11 + a42 * b21 + a43 * b31 + a44 * b41; + te[ 7 ] = a41 * b12 + a42 * b22 + a43 * b32 + a44 * b42; + te[ 11 ] = a41 * b13 + a42 * b23 + a43 * b33 + a44 * b43; + te[ 15 ] = a41 * b14 + a42 * b24 + a43 * b34 + a44 * b44; + + return this; + + }, + + multiplyScalar: function ( s ) { + + var te = this.elements; + + te[ 0 ] *= s; te[ 4 ] *= s; te[ 8 ] *= s; te[ 12 ] *= s; + te[ 1 ] *= s; te[ 5 ] *= s; te[ 9 ] *= s; te[ 13 ] *= s; + te[ 2 ] *= s; te[ 6 ] *= s; te[ 10 ] *= s; te[ 14 ] *= s; + te[ 3 ] *= s; te[ 7 ] *= s; te[ 11 ] *= s; te[ 15 ] *= s; + + return this; + + }, + + applyToBufferAttribute: function () { + + var v1 = new Vector3(); + + return function applyToBufferAttribute( attribute ) { + + for ( var i = 0, l = attribute.count; i < l; i ++ ) { + + v1.x = attribute.getX( i ); + v1.y = attribute.getY( i ); + v1.z = attribute.getZ( i ); + + v1.applyMatrix4( this ); + + attribute.setXYZ( i, v1.x, v1.y, v1.z ); + + } + + return attribute; + + }; + + }(), + + determinant: function () { + + var te = this.elements; + + var n11 = te[ 0 ], n12 = te[ 4 ], n13 = te[ 8 ], n14 = te[ 12 ]; + var n21 = te[ 1 ], n22 = te[ 5 ], n23 = te[ 9 ], n24 = te[ 13 ]; + var n31 = te[ 2 ], n32 = te[ 6 ], n33 = te[ 10 ], n34 = te[ 14 ]; + var n41 = te[ 3 ], n42 = te[ 7 ], n43 = te[ 11 ], n44 = te[ 15 ]; + + //TODO: make this more efficient + //( based on http://www.euclideanspace.com/maths/algebra/matrix/functions/inverse/fourD/index.htm ) + + return ( + n41 * ( + + n14 * n23 * n32 + - n13 * n24 * n32 + - n14 * n22 * n33 + + n12 * n24 * n33 + + n13 * n22 * n34 + - n12 * n23 * n34 + ) + + n42 * ( + + n11 * n23 * n34 + - n11 * n24 * n33 + + n14 * n21 * n33 + - n13 * n21 * n34 + + n13 * n24 * n31 + - n14 * n23 * n31 + ) + + n43 * ( + + n11 * n24 * n32 + - n11 * n22 * n34 + - n14 * n21 * n32 + + n12 * n21 * n34 + + n14 * n22 * n31 + - n12 * n24 * n31 + ) + + n44 * ( + - n13 * n22 * n31 + - n11 * n23 * n32 + + n11 * n22 * n33 + + n13 * n21 * n32 + - n12 * n21 * n33 + + n12 * n23 * n31 + ) + + ); + + }, + + transpose: function () { + + var te = this.elements; + var tmp; + + tmp = te[ 1 ]; te[ 1 ] = te[ 4 ]; te[ 4 ] = tmp; + tmp = te[ 2 ]; te[ 2 ] = te[ 8 ]; te[ 8 ] = tmp; + tmp = te[ 6 ]; te[ 6 ] = te[ 9 ]; te[ 9 ] = tmp; + + tmp = te[ 3 ]; te[ 3 ] = te[ 12 ]; te[ 12 ] = tmp; + tmp = te[ 7 ]; te[ 7 ] = te[ 13 ]; te[ 13 ] = tmp; + tmp = te[ 11 ]; te[ 11 ] = te[ 14 ]; te[ 14 ] = tmp; + + return this; + + }, + + setPosition: function ( v ) { + + var te = this.elements; + + te[ 12 ] = v.x; + te[ 13 ] = v.y; + te[ 14 ] = v.z; + + return this; + + }, + + getInverse: function ( m, throwOnDegenerate ) { + + // based on http://www.euclideanspace.com/maths/algebra/matrix/functions/inverse/fourD/index.htm + var te = this.elements, + me = m.elements, + + n11 = me[ 0 ], n21 = me[ 1 ], n31 = me[ 2 ], n41 = me[ 3 ], + n12 = me[ 4 ], n22 = me[ 5 ], n32 = me[ 6 ], n42 = me[ 7 ], + n13 = me[ 8 ], n23 = me[ 9 ], n33 = me[ 10 ], n43 = me[ 11 ], + n14 = me[ 12 ], n24 = me[ 13 ], n34 = me[ 14 ], n44 = me[ 15 ], + + t11 = n23 * n34 * n42 - n24 * n33 * n42 + n24 * n32 * n43 - n22 * n34 * n43 - n23 * n32 * n44 + n22 * n33 * n44, + t12 = n14 * n33 * n42 - n13 * n34 * n42 - n14 * n32 * n43 + n12 * n34 * n43 + n13 * n32 * n44 - n12 * n33 * n44, + t13 = n13 * n24 * n42 - n14 * n23 * n42 + n14 * n22 * n43 - n12 * n24 * n43 - n13 * n22 * n44 + n12 * n23 * n44, + t14 = n14 * n23 * n32 - n13 * n24 * n32 - n14 * n22 * n33 + n12 * n24 * n33 + n13 * n22 * n34 - n12 * n23 * n34; + + var det = n11 * t11 + n21 * t12 + n31 * t13 + n41 * t14; + + if ( det === 0 ) { + + var msg = "THREE.Matrix4: .getInverse() can't invert matrix, determinant is 0"; + + if ( throwOnDegenerate === true ) { + + throw new Error( msg ); + + } else { + + console.warn( msg ); + + } + + return this.identity(); + + } + + var detInv = 1 / det; + + te[ 0 ] = t11 * detInv; + te[ 1 ] = ( n24 * n33 * n41 - n23 * n34 * n41 - n24 * n31 * n43 + n21 * n34 * n43 + n23 * n31 * n44 - n21 * n33 * n44 ) * detInv; + te[ 2 ] = ( n22 * n34 * n41 - n24 * n32 * n41 + n24 * n31 * n42 - n21 * n34 * n42 - n22 * n31 * n44 + n21 * n32 * n44 ) * detInv; + te[ 3 ] = ( n23 * n32 * n41 - n22 * n33 * n41 - n23 * n31 * n42 + n21 * n33 * n42 + n22 * n31 * n43 - n21 * n32 * n43 ) * detInv; + + te[ 4 ] = t12 * detInv; + te[ 5 ] = ( n13 * n34 * n41 - n14 * n33 * n41 + n14 * n31 * n43 - n11 * n34 * n43 - n13 * n31 * n44 + n11 * n33 * n44 ) * detInv; + te[ 6 ] = ( n14 * n32 * n41 - n12 * n34 * n41 - n14 * n31 * n42 + n11 * n34 * n42 + n12 * n31 * n44 - n11 * n32 * n44 ) * detInv; + te[ 7 ] = ( n12 * n33 * n41 - n13 * n32 * n41 + n13 * n31 * n42 - n11 * n33 * n42 - n12 * n31 * n43 + n11 * n32 * n43 ) * detInv; + + te[ 8 ] = t13 * detInv; + te[ 9 ] = ( n14 * n23 * n41 - n13 * n24 * n41 - n14 * n21 * n43 + n11 * n24 * n43 + n13 * n21 * n44 - n11 * n23 * n44 ) * detInv; + te[ 10 ] = ( n12 * n24 * n41 - n14 * n22 * n41 + n14 * n21 * n42 - n11 * n24 * n42 - n12 * n21 * n44 + n11 * n22 * n44 ) * detInv; + te[ 11 ] = ( n13 * n22 * n41 - n12 * n23 * n41 - n13 * n21 * n42 + n11 * n23 * n42 + n12 * n21 * n43 - n11 * n22 * n43 ) * detInv; + + te[ 12 ] = t14 * detInv; + te[ 13 ] = ( n13 * n24 * n31 - n14 * n23 * n31 + n14 * n21 * n33 - n11 * n24 * n33 - n13 * n21 * n34 + n11 * n23 * n34 ) * detInv; + te[ 14 ] = ( n14 * n22 * n31 - n12 * n24 * n31 - n14 * n21 * n32 + n11 * n24 * n32 + n12 * n21 * n34 - n11 * n22 * n34 ) * detInv; + te[ 15 ] = ( n12 * n23 * n31 - n13 * n22 * n31 + n13 * n21 * n32 - n11 * n23 * n32 - n12 * n21 * n33 + n11 * n22 * n33 ) * detInv; + + return this; + + }, + + scale: function ( v ) { + + var te = this.elements; + var x = v.x, y = v.y, z = v.z; + + te[ 0 ] *= x; te[ 4 ] *= y; te[ 8 ] *= z; + te[ 1 ] *= x; te[ 5 ] *= y; te[ 9 ] *= z; + te[ 2 ] *= x; te[ 6 ] *= y; te[ 10 ] *= z; + te[ 3 ] *= x; te[ 7 ] *= y; te[ 11 ] *= z; + + return this; + + }, + + getMaxScaleOnAxis: function () { + + var te = this.elements; + + var scaleXSq = te[ 0 ] * te[ 0 ] + te[ 1 ] * te[ 1 ] + te[ 2 ] * te[ 2 ]; + var scaleYSq = te[ 4 ] * te[ 4 ] + te[ 5 ] * te[ 5 ] + te[ 6 ] * te[ 6 ]; + var scaleZSq = te[ 8 ] * te[ 8 ] + te[ 9 ] * te[ 9 ] + te[ 10 ] * te[ 10 ]; + + return Math.sqrt( Math.max( scaleXSq, scaleYSq, scaleZSq ) ); + + }, + + makeTranslation: function ( x, y, z ) { + + this.set( + + 1, 0, 0, x, + 0, 1, 0, y, + 0, 0, 1, z, + 0, 0, 0, 1 + + ); + + return this; + + }, + + makeRotationX: function ( theta ) { + + var c = Math.cos( theta ), s = Math.sin( theta ); + + this.set( + + 1, 0, 0, 0, + 0, c, - s, 0, + 0, s, c, 0, + 0, 0, 0, 1 + + ); + + return this; + + }, + + makeRotationY: function ( theta ) { + + var c = Math.cos( theta ), s = Math.sin( theta ); + + this.set( + + c, 0, s, 0, + 0, 1, 0, 0, + - s, 0, c, 0, + 0, 0, 0, 1 + + ); + + return this; + + }, + + makeRotationZ: function ( theta ) { + + var c = Math.cos( theta ), s = Math.sin( theta ); + + this.set( + + c, - s, 0, 0, + s, c, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1 + + ); + + return this; + + }, + + makeRotationAxis: function ( axis, angle ) { + + // Based on http://www.gamedev.net/reference/articles/article1199.asp + + var c = Math.cos( angle ); + var s = Math.sin( angle ); + var t = 1 - c; + var x = axis.x, y = axis.y, z = axis.z; + var tx = t * x, ty = t * y; + + this.set( + + tx * x + c, tx * y - s * z, tx * z + s * y, 0, + tx * y + s * z, ty * y + c, ty * z - s * x, 0, + tx * z - s * y, ty * z + s * x, t * z * z + c, 0, + 0, 0, 0, 1 + + ); + + return this; + + }, + + makeScale: function ( x, y, z ) { + + this.set( + + x, 0, 0, 0, + 0, y, 0, 0, + 0, 0, z, 0, + 0, 0, 0, 1 + + ); + + return this; + + }, + + makeShear: function ( x, y, z ) { + + this.set( + + 1, y, z, 0, + x, 1, z, 0, + x, y, 1, 0, + 0, 0, 0, 1 + + ); + + return this; + + }, + + compose: function ( position, quaternion, scale ) { + + var te = this.elements; + + var x = quaternion._x, y = quaternion._y, z = quaternion._z, w = quaternion._w; + var x2 = x + x, y2 = y + y, z2 = z + z; + var xx = x * x2, xy = x * y2, xz = x * z2; + var yy = y * y2, yz = y * z2, zz = z * z2; + var wx = w * x2, wy = w * y2, wz = w * z2; + + var sx = scale.x, sy = scale.y, sz = scale.z; + + te[ 0 ] = ( 1 - ( yy + zz ) ) * sx; + te[ 1 ] = ( xy + wz ) * sx; + te[ 2 ] = ( xz - wy ) * sx; + te[ 3 ] = 0; + + te[ 4 ] = ( xy - wz ) * sy; + te[ 5 ] = ( 1 - ( xx + zz ) ) * sy; + te[ 6 ] = ( yz + wx ) * sy; + te[ 7 ] = 0; + + te[ 8 ] = ( xz + wy ) * sz; + te[ 9 ] = ( yz - wx ) * sz; + te[ 10 ] = ( 1 - ( xx + yy ) ) * sz; + te[ 11 ] = 0; + + te[ 12 ] = position.x; + te[ 13 ] = position.y; + te[ 14 ] = position.z; + te[ 15 ] = 1; + + return this; + + }, + + decompose: function () { + + var vector = new Vector3(); + var matrix = new Matrix4(); + + return function decompose( position, quaternion, scale ) { + + var te = this.elements; + + var sx = vector.set( te[ 0 ], te[ 1 ], te[ 2 ] ).length(); + var sy = vector.set( te[ 4 ], te[ 5 ], te[ 6 ] ).length(); + var sz = vector.set( te[ 8 ], te[ 9 ], te[ 10 ] ).length(); + + // if determine is negative, we need to invert one scale + var det = this.determinant(); + if ( det < 0 ) sx = - sx; + + position.x = te[ 12 ]; + position.y = te[ 13 ]; + position.z = te[ 14 ]; + + // scale the rotation part + matrix.copy( this ); + + var invSX = 1 / sx; + var invSY = 1 / sy; + var invSZ = 1 / sz; + + matrix.elements[ 0 ] *= invSX; + matrix.elements[ 1 ] *= invSX; + matrix.elements[ 2 ] *= invSX; + + matrix.elements[ 4 ] *= invSY; + matrix.elements[ 5 ] *= invSY; + matrix.elements[ 6 ] *= invSY; + + matrix.elements[ 8 ] *= invSZ; + matrix.elements[ 9 ] *= invSZ; + matrix.elements[ 10 ] *= invSZ; + + quaternion.setFromRotationMatrix( matrix ); + + scale.x = sx; + scale.y = sy; + scale.z = sz; + + return this; + + }; + + }(), + + makePerspective: function ( left, right, top, bottom, near, far ) { + + if ( far === undefined ) { + + console.warn( 'THREE.Matrix4: .makePerspective() has been redefined and has a new signature. Please check the docs.' ); + + } + + var te = this.elements; + var x = 2 * near / ( right - left ); + var y = 2 * near / ( top - bottom ); + + var a = ( right + left ) / ( right - left ); + var b = ( top + bottom ) / ( top - bottom ); + var c = - ( far + near ) / ( far - near ); + var d = - 2 * far * near / ( far - near ); + + te[ 0 ] = x; te[ 4 ] = 0; te[ 8 ] = a; te[ 12 ] = 0; + te[ 1 ] = 0; te[ 5 ] = y; te[ 9 ] = b; te[ 13 ] = 0; + te[ 2 ] = 0; te[ 6 ] = 0; te[ 10 ] = c; te[ 14 ] = d; + te[ 3 ] = 0; te[ 7 ] = 0; te[ 11 ] = - 1; te[ 15 ] = 0; + + return this; + + }, + + makeOrthographic: function ( left, right, top, bottom, near, far ) { + + var te = this.elements; + var w = 1.0 / ( right - left ); + var h = 1.0 / ( top - bottom ); + var p = 1.0 / ( far - near ); + + var x = ( right + left ) * w; + var y = ( top + bottom ) * h; + var z = ( far + near ) * p; + + te[ 0 ] = 2 * w; te[ 4 ] = 0; te[ 8 ] = 0; te[ 12 ] = - x; + te[ 1 ] = 0; te[ 5 ] = 2 * h; te[ 9 ] = 0; te[ 13 ] = - y; + te[ 2 ] = 0; te[ 6 ] = 0; te[ 10 ] = - 2 * p; te[ 14 ] = - z; + te[ 3 ] = 0; te[ 7 ] = 0; te[ 11 ] = 0; te[ 15 ] = 1; + + return this; + + }, + + equals: function ( matrix ) { + + var te = this.elements; + var me = matrix.elements; + + for ( var i = 0; i < 16; i ++ ) { + + if ( te[ i ] !== me[ i ] ) return false; + + } + + return true; + + }, + + fromArray: function ( array, offset ) { + + if ( offset === undefined ) offset = 0; + + for ( var i = 0; i < 16; i ++ ) { + + this.elements[ i ] = array[ i + offset ]; + + } + + return this; + + }, + + toArray: function ( array, offset ) { + + if ( array === undefined ) array = []; + if ( offset === undefined ) offset = 0; + + var te = this.elements; + + array[ offset ] = te[ 0 ]; + array[ offset + 1 ] = te[ 1 ]; + array[ offset + 2 ] = te[ 2 ]; + array[ offset + 3 ] = te[ 3 ]; + + array[ offset + 4 ] = te[ 4 ]; + array[ offset + 5 ] = te[ 5 ]; + array[ offset + 6 ] = te[ 6 ]; + array[ offset + 7 ] = te[ 7 ]; + + array[ offset + 8 ] = te[ 8 ]; + array[ offset + 9 ] = te[ 9 ]; + array[ offset + 10 ] = te[ 10 ]; + array[ offset + 11 ] = te[ 11 ]; + + array[ offset + 12 ] = te[ 12 ]; + array[ offset + 13 ] = te[ 13 ]; + array[ offset + 14 ] = te[ 14 ]; + array[ offset + 15 ] = te[ 15 ]; + + return array; + + } + + } ); + + /** + * @author mikael emtinger / http://gomo.se/ + * @author alteredq / http://alteredqualia.com/ + * @author WestLangley / http://github.com/WestLangley + * @author bhouston / http://clara.io + */ + + function Quaternion( x, y, z, w ) { + + this._x = x || 0; + this._y = y || 0; + this._z = z || 0; + this._w = ( w !== undefined ) ? w : 1; + + } + + Object.assign( Quaternion, { + + slerp: function ( qa, qb, qm, t ) { + + return qm.copy( qa ).slerp( qb, t ); + + }, + + slerpFlat: function ( dst, dstOffset, src0, srcOffset0, src1, srcOffset1, t ) { + + // fuzz-free, array-based Quaternion SLERP operation + + var x0 = src0[ srcOffset0 + 0 ], + y0 = src0[ srcOffset0 + 1 ], + z0 = src0[ srcOffset0 + 2 ], + w0 = src0[ srcOffset0 + 3 ], + + x1 = src1[ srcOffset1 + 0 ], + y1 = src1[ srcOffset1 + 1 ], + z1 = src1[ srcOffset1 + 2 ], + w1 = src1[ srcOffset1 + 3 ]; + + if ( w0 !== w1 || x0 !== x1 || y0 !== y1 || z0 !== z1 ) { + + var s = 1 - t, + + cos = x0 * x1 + y0 * y1 + z0 * z1 + w0 * w1, + + dir = ( cos >= 0 ? 1 : - 1 ), + sqrSin = 1 - cos * cos; + + // Skip the Slerp for tiny steps to avoid numeric problems: + if ( sqrSin > Number.EPSILON ) { + + var sin = Math.sqrt( sqrSin ), + len = Math.atan2( sin, cos * dir ); + + s = Math.sin( s * len ) / sin; + t = Math.sin( t * len ) / sin; + + } + + var tDir = t * dir; + + x0 = x0 * s + x1 * tDir; + y0 = y0 * s + y1 * tDir; + z0 = z0 * s + z1 * tDir; + w0 = w0 * s + w1 * tDir; + + // Normalize in case we just did a lerp: + if ( s === 1 - t ) { + + var f = 1 / Math.sqrt( x0 * x0 + y0 * y0 + z0 * z0 + w0 * w0 ); + + x0 *= f; + y0 *= f; + z0 *= f; + w0 *= f; + + } + + } + + dst[ dstOffset ] = x0; + dst[ dstOffset + 1 ] = y0; + dst[ dstOffset + 2 ] = z0; + dst[ dstOffset + 3 ] = w0; + + } + + } ); + + Object.defineProperties( Quaternion.prototype, { + + x: { + + get: function () { + + return this._x; + + }, + + set: function ( value ) { + + this._x = value; + this.onChangeCallback(); + + } + + }, + + y: { + + get: function () { + + return this._y; + + }, + + set: function ( value ) { + + this._y = value; + this.onChangeCallback(); + + } + + }, + + z: { + + get: function () { + + return this._z; + + }, + + set: function ( value ) { + + this._z = value; + this.onChangeCallback(); + + } + + }, + + w: { + + get: function () { + + return this._w; + + }, + + set: function ( value ) { + + this._w = value; + this.onChangeCallback(); + + } + + } + + } ); + + Object.assign( Quaternion.prototype, { + + set: function ( x, y, z, w ) { + + this._x = x; + this._y = y; + this._z = z; + this._w = w; + + this.onChangeCallback(); + + return this; + + }, + + clone: function () { + + return new this.constructor( this._x, this._y, this._z, this._w ); + + }, + + copy: function ( quaternion ) { + + this._x = quaternion.x; + this._y = quaternion.y; + this._z = quaternion.z; + this._w = quaternion.w; + + this.onChangeCallback(); + + return this; + + }, + + setFromEuler: function ( euler, update ) { + + if ( ! ( euler && euler.isEuler ) ) { + + throw new Error( 'THREE.Quaternion: .setFromEuler() now expects an Euler rotation rather than a Vector3 and order.' ); + + } + + var x = euler._x, y = euler._y, z = euler._z, order = euler.order; + + // http://www.mathworks.com/matlabcentral/fileexchange/ + // 20696-function-to-convert-between-dcm-euler-angles-quaternions-and-euler-vectors/ + // content/SpinCalc.m + + var cos = Math.cos; + var sin = Math.sin; + + var c1 = cos( x / 2 ); + var c2 = cos( y / 2 ); + var c3 = cos( z / 2 ); + + var s1 = sin( x / 2 ); + var s2 = sin( y / 2 ); + var s3 = sin( z / 2 ); + + if ( order === 'XYZ' ) { + + this._x = s1 * c2 * c3 + c1 * s2 * s3; + this._y = c1 * s2 * c3 - s1 * c2 * s3; + this._z = c1 * c2 * s3 + s1 * s2 * c3; + this._w = c1 * c2 * c3 - s1 * s2 * s3; + + } else if ( order === 'YXZ' ) { + + this._x = s1 * c2 * c3 + c1 * s2 * s3; + this._y = c1 * s2 * c3 - s1 * c2 * s3; + this._z = c1 * c2 * s3 - s1 * s2 * c3; + this._w = c1 * c2 * c3 + s1 * s2 * s3; + + } else if ( order === 'ZXY' ) { + + this._x = s1 * c2 * c3 - c1 * s2 * s3; + this._y = c1 * s2 * c3 + s1 * c2 * s3; + this._z = c1 * c2 * s3 + s1 * s2 * c3; + this._w = c1 * c2 * c3 - s1 * s2 * s3; + + } else if ( order === 'ZYX' ) { + + this._x = s1 * c2 * c3 - c1 * s2 * s3; + this._y = c1 * s2 * c3 + s1 * c2 * s3; + this._z = c1 * c2 * s3 - s1 * s2 * c3; + this._w = c1 * c2 * c3 + s1 * s2 * s3; + + } else if ( order === 'YZX' ) { + + this._x = s1 * c2 * c3 + c1 * s2 * s3; + this._y = c1 * s2 * c3 + s1 * c2 * s3; + this._z = c1 * c2 * s3 - s1 * s2 * c3; + this._w = c1 * c2 * c3 - s1 * s2 * s3; + + } else if ( order === 'XZY' ) { + + this._x = s1 * c2 * c3 - c1 * s2 * s3; + this._y = c1 * s2 * c3 - s1 * c2 * s3; + this._z = c1 * c2 * s3 + s1 * s2 * c3; + this._w = c1 * c2 * c3 + s1 * s2 * s3; + + } + + if ( update !== false ) this.onChangeCallback(); + + return this; + + }, + + setFromAxisAngle: function ( axis, angle ) { + + // http://www.euclideanspace.com/maths/geometry/rotations/conversions/angleToQuaternion/index.htm + + // assumes axis is normalized + + var halfAngle = angle / 2, s = Math.sin( halfAngle ); + + this._x = axis.x * s; + this._y = axis.y * s; + this._z = axis.z * s; + this._w = Math.cos( halfAngle ); + + this.onChangeCallback(); + + return this; + + }, + + setFromRotationMatrix: function ( m ) { + + // http://www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToQuaternion/index.htm + + // assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled) + + var te = m.elements, + + m11 = te[ 0 ], m12 = te[ 4 ], m13 = te[ 8 ], + m21 = te[ 1 ], m22 = te[ 5 ], m23 = te[ 9 ], + m31 = te[ 2 ], m32 = te[ 6 ], m33 = te[ 10 ], + + trace = m11 + m22 + m33, + s; + + if ( trace > 0 ) { + + s = 0.5 / Math.sqrt( trace + 1.0 ); + + this._w = 0.25 / s; + this._x = ( m32 - m23 ) * s; + this._y = ( m13 - m31 ) * s; + this._z = ( m21 - m12 ) * s; + + } else if ( m11 > m22 && m11 > m33 ) { + + s = 2.0 * Math.sqrt( 1.0 + m11 - m22 - m33 ); + + this._w = ( m32 - m23 ) / s; + this._x = 0.25 * s; + this._y = ( m12 + m21 ) / s; + this._z = ( m13 + m31 ) / s; + + } else if ( m22 > m33 ) { + + s = 2.0 * Math.sqrt( 1.0 + m22 - m11 - m33 ); + + this._w = ( m13 - m31 ) / s; + this._x = ( m12 + m21 ) / s; + this._y = 0.25 * s; + this._z = ( m23 + m32 ) / s; + + } else { + + s = 2.0 * Math.sqrt( 1.0 + m33 - m11 - m22 ); + + this._w = ( m21 - m12 ) / s; + this._x = ( m13 + m31 ) / s; + this._y = ( m23 + m32 ) / s; + this._z = 0.25 * s; + + } + + this.onChangeCallback(); + + return this; + + }, + + setFromUnitVectors: function () { + + // assumes direction vectors vFrom and vTo are normalized + + var v1 = new Vector3(); + var r; + + var EPS = 0.000001; + + return function setFromUnitVectors( vFrom, vTo ) { + + if ( v1 === undefined ) v1 = new Vector3(); + + r = vFrom.dot( vTo ) + 1; + + if ( r < EPS ) { + + r = 0; + + if ( Math.abs( vFrom.x ) > Math.abs( vFrom.z ) ) { + + v1.set( - vFrom.y, vFrom.x, 0 ); + + } else { + + v1.set( 0, - vFrom.z, vFrom.y ); + + } + + } else { + + v1.crossVectors( vFrom, vTo ); + + } + + this._x = v1.x; + this._y = v1.y; + this._z = v1.z; + this._w = r; + + return this.normalize(); + + }; + + }(), + + inverse: function () { + + // quaternion is assumed to have unit length + + return this.conjugate(); + + }, + + conjugate: function () { + + this._x *= - 1; + this._y *= - 1; + this._z *= - 1; + + this.onChangeCallback(); + + return this; + + }, + + dot: function ( v ) { + + return this._x * v._x + this._y * v._y + this._z * v._z + this._w * v._w; + + }, + + lengthSq: function () { + + return this._x * this._x + this._y * this._y + this._z * this._z + this._w * this._w; + + }, + + length: function () { + + return Math.sqrt( this._x * this._x + this._y * this._y + this._z * this._z + this._w * this._w ); + + }, + + normalize: function () { + + var l = this.length(); + + if ( l === 0 ) { + + this._x = 0; + this._y = 0; + this._z = 0; + this._w = 1; + + } else { + + l = 1 / l; + + this._x = this._x * l; + this._y = this._y * l; + this._z = this._z * l; + this._w = this._w * l; + + } + + this.onChangeCallback(); + + return this; + + }, + + multiply: function ( q, p ) { + + if ( p !== undefined ) { + + console.warn( 'THREE.Quaternion: .multiply() now only accepts one argument. Use .multiplyQuaternions( a, b ) instead.' ); + return this.multiplyQuaternions( q, p ); + + } + + return this.multiplyQuaternions( this, q ); + + }, + + premultiply: function ( q ) { + + return this.multiplyQuaternions( q, this ); + + }, + + multiplyQuaternions: function ( a, b ) { + + // from http://www.euclideanspace.com/maths/algebra/realNormedAlgebra/quaternions/code/index.htm + + var qax = a._x, qay = a._y, qaz = a._z, qaw = a._w; + var qbx = b._x, qby = b._y, qbz = b._z, qbw = b._w; + + this._x = qax * qbw + qaw * qbx + qay * qbz - qaz * qby; + this._y = qay * qbw + qaw * qby + qaz * qbx - qax * qbz; + this._z = qaz * qbw + qaw * qbz + qax * qby - qay * qbx; + this._w = qaw * qbw - qax * qbx - qay * qby - qaz * qbz; + + this.onChangeCallback(); + + return this; + + }, + + slerp: function ( qb, t ) { + + if ( t === 0 ) return this; + if ( t === 1 ) return this.copy( qb ); + + var x = this._x, y = this._y, z = this._z, w = this._w; + + // http://www.euclideanspace.com/maths/algebra/realNormedAlgebra/quaternions/slerp/ + + var cosHalfTheta = w * qb._w + x * qb._x + y * qb._y + z * qb._z; + + if ( cosHalfTheta < 0 ) { + + this._w = - qb._w; + this._x = - qb._x; + this._y = - qb._y; + this._z = - qb._z; + + cosHalfTheta = - cosHalfTheta; + + } else { + + this.copy( qb ); + + } + + if ( cosHalfTheta >= 1.0 ) { + + this._w = w; + this._x = x; + this._y = y; + this._z = z; + + return this; + + } + + var sinHalfTheta = Math.sqrt( 1.0 - cosHalfTheta * cosHalfTheta ); + + if ( Math.abs( sinHalfTheta ) < 0.001 ) { + + this._w = 0.5 * ( w + this._w ); + this._x = 0.5 * ( x + this._x ); + this._y = 0.5 * ( y + this._y ); + this._z = 0.5 * ( z + this._z ); + + return this; + + } + + var halfTheta = Math.atan2( sinHalfTheta, cosHalfTheta ); + var ratioA = Math.sin( ( 1 - t ) * halfTheta ) / sinHalfTheta, + ratioB = Math.sin( t * halfTheta ) / sinHalfTheta; + + this._w = ( w * ratioA + this._w * ratioB ); + this._x = ( x * ratioA + this._x * ratioB ); + this._y = ( y * ratioA + this._y * ratioB ); + this._z = ( z * ratioA + this._z * ratioB ); + + this.onChangeCallback(); + + return this; + + }, + + equals: function ( quaternion ) { + + return ( quaternion._x === this._x ) && ( quaternion._y === this._y ) && ( quaternion._z === this._z ) && ( quaternion._w === this._w ); + + }, + + fromArray: function ( array, offset ) { + + if ( offset === undefined ) offset = 0; + + this._x = array[ offset ]; + this._y = array[ offset + 1 ]; + this._z = array[ offset + 2 ]; + this._w = array[ offset + 3 ]; + + this.onChangeCallback(); + + return this; + + }, + + toArray: function ( array, offset ) { + + if ( array === undefined ) array = []; + if ( offset === undefined ) offset = 0; + + array[ offset ] = this._x; + array[ offset + 1 ] = this._y; + array[ offset + 2 ] = this._z; + array[ offset + 3 ] = this._w; + + return array; + + }, + + onChange: function ( callback ) { + + this.onChangeCallback = callback; + + return this; + + }, + + onChangeCallback: function () {} + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author kile / http://kile.stravaganza.org/ + * @author philogb / http://blog.thejit.org/ + * @author mikael emtinger / http://gomo.se/ + * @author egraether / http://egraether.com/ + * @author WestLangley / http://github.com/WestLangley + */ + + function Vector3( x, y, z ) { + + this.x = x || 0; + this.y = y || 0; + this.z = z || 0; + + } + + Object.assign( Vector3.prototype, { + + isVector3: true, + + set: function ( x, y, z ) { + + this.x = x; + this.y = y; + this.z = z; + + return this; + + }, + + setScalar: function ( scalar ) { + + this.x = scalar; + this.y = scalar; + this.z = scalar; + + return this; + + }, + + setX: function ( x ) { + + this.x = x; + + return this; + + }, + + setY: function ( y ) { + + this.y = y; + + return this; + + }, + + setZ: function ( z ) { + + this.z = z; + + return this; + + }, + + setComponent: function ( index, value ) { + + switch ( index ) { + + case 0: this.x = value; break; + case 1: this.y = value; break; + case 2: this.z = value; break; + default: throw new Error( 'index is out of range: ' + index ); + + } + + return this; + + }, + + getComponent: function ( index ) { + + switch ( index ) { + + case 0: return this.x; + case 1: return this.y; + case 2: return this.z; + default: throw new Error( 'index is out of range: ' + index ); + + } + + }, + + clone: function () { + + return new this.constructor( this.x, this.y, this.z ); + + }, + + copy: function ( v ) { + + this.x = v.x; + this.y = v.y; + this.z = v.z; + + return this; + + }, + + add: function ( v, w ) { + + if ( w !== undefined ) { + + console.warn( 'THREE.Vector3: .add() now only accepts one argument. Use .addVectors( a, b ) instead.' ); + return this.addVectors( v, w ); + + } + + this.x += v.x; + this.y += v.y; + this.z += v.z; + + return this; + + }, + + addScalar: function ( s ) { + + this.x += s; + this.y += s; + this.z += s; + + return this; + + }, + + addVectors: function ( a, b ) { + + this.x = a.x + b.x; + this.y = a.y + b.y; + this.z = a.z + b.z; + + return this; + + }, + + addScaledVector: function ( v, s ) { + + this.x += v.x * s; + this.y += v.y * s; + this.z += v.z * s; + + return this; + + }, + + sub: function ( v, w ) { + + if ( w !== undefined ) { + + console.warn( 'THREE.Vector3: .sub() now only accepts one argument. Use .subVectors( a, b ) instead.' ); + return this.subVectors( v, w ); + + } + + this.x -= v.x; + this.y -= v.y; + this.z -= v.z; + + return this; + + }, + + subScalar: function ( s ) { + + this.x -= s; + this.y -= s; + this.z -= s; + + return this; + + }, + + subVectors: function ( a, b ) { + + this.x = a.x - b.x; + this.y = a.y - b.y; + this.z = a.z - b.z; + + return this; + + }, + + multiply: function ( v, w ) { + + if ( w !== undefined ) { + + console.warn( 'THREE.Vector3: .multiply() now only accepts one argument. Use .multiplyVectors( a, b ) instead.' ); + return this.multiplyVectors( v, w ); + + } + + this.x *= v.x; + this.y *= v.y; + this.z *= v.z; + + return this; + + }, + + multiplyScalar: function ( scalar ) { + + this.x *= scalar; + this.y *= scalar; + this.z *= scalar; + + return this; + + }, + + multiplyVectors: function ( a, b ) { + + this.x = a.x * b.x; + this.y = a.y * b.y; + this.z = a.z * b.z; + + return this; + + }, + + applyEuler: function () { + + var quaternion = new Quaternion(); + + return function applyEuler( euler ) { + + if ( ! ( euler && euler.isEuler ) ) { + + console.error( 'THREE.Vector3: .applyEuler() now expects an Euler rotation rather than a Vector3 and order.' ); + + } + + return this.applyQuaternion( quaternion.setFromEuler( euler ) ); + + }; + + }(), + + applyAxisAngle: function () { + + var quaternion = new Quaternion(); + + return function applyAxisAngle( axis, angle ) { + + return this.applyQuaternion( quaternion.setFromAxisAngle( axis, angle ) ); + + }; + + }(), + + applyMatrix3: function ( m ) { + + var x = this.x, y = this.y, z = this.z; + var e = m.elements; + + this.x = e[ 0 ] * x + e[ 3 ] * y + e[ 6 ] * z; + this.y = e[ 1 ] * x + e[ 4 ] * y + e[ 7 ] * z; + this.z = e[ 2 ] * x + e[ 5 ] * y + e[ 8 ] * z; + + return this; + + }, + + applyMatrix4: function ( m ) { + + var x = this.x, y = this.y, z = this.z; + var e = m.elements; + + var w = 1 / ( e[ 3 ] * x + e[ 7 ] * y + e[ 11 ] * z + e[ 15 ] ); + + this.x = ( e[ 0 ] * x + e[ 4 ] * y + e[ 8 ] * z + e[ 12 ] ) * w; + this.y = ( e[ 1 ] * x + e[ 5 ] * y + e[ 9 ] * z + e[ 13 ] ) * w; + this.z = ( e[ 2 ] * x + e[ 6 ] * y + e[ 10 ] * z + e[ 14 ] ) * w; + + return this; + + }, + + applyQuaternion: function ( q ) { + + var x = this.x, y = this.y, z = this.z; + var qx = q.x, qy = q.y, qz = q.z, qw = q.w; + + // calculate quat * vector + + var ix = qw * x + qy * z - qz * y; + var iy = qw * y + qz * x - qx * z; + var iz = qw * z + qx * y - qy * x; + var iw = - qx * x - qy * y - qz * z; + + // calculate result * inverse quat + + this.x = ix * qw + iw * - qx + iy * - qz - iz * - qy; + this.y = iy * qw + iw * - qy + iz * - qx - ix * - qz; + this.z = iz * qw + iw * - qz + ix * - qy - iy * - qx; + + return this; + + }, + + project: function () { + + var matrix = new Matrix4(); + + return function project( camera ) { + + matrix.multiplyMatrices( camera.projectionMatrix, matrix.getInverse( camera.matrixWorld ) ); + return this.applyMatrix4( matrix ); + + }; + + }(), + + unproject: function () { + + var matrix = new Matrix4(); + + return function unproject( camera ) { + + matrix.multiplyMatrices( camera.matrixWorld, matrix.getInverse( camera.projectionMatrix ) ); + return this.applyMatrix4( matrix ); + + }; + + }(), + + transformDirection: function ( m ) { + + // input: THREE.Matrix4 affine matrix + // vector interpreted as a direction + + var x = this.x, y = this.y, z = this.z; + var e = m.elements; + + this.x = e[ 0 ] * x + e[ 4 ] * y + e[ 8 ] * z; + this.y = e[ 1 ] * x + e[ 5 ] * y + e[ 9 ] * z; + this.z = e[ 2 ] * x + e[ 6 ] * y + e[ 10 ] * z; + + return this.normalize(); + + }, + + divide: function ( v ) { + + this.x /= v.x; + this.y /= v.y; + this.z /= v.z; + + return this; + + }, + + divideScalar: function ( scalar ) { + + return this.multiplyScalar( 1 / scalar ); + + }, + + min: function ( v ) { + + this.x = Math.min( this.x, v.x ); + this.y = Math.min( this.y, v.y ); + this.z = Math.min( this.z, v.z ); + + return this; + + }, + + max: function ( v ) { + + this.x = Math.max( this.x, v.x ); + this.y = Math.max( this.y, v.y ); + this.z = Math.max( this.z, v.z ); + + return this; + + }, + + clamp: function ( min, max ) { + + // assumes min < max, componentwise + + this.x = Math.max( min.x, Math.min( max.x, this.x ) ); + this.y = Math.max( min.y, Math.min( max.y, this.y ) ); + this.z = Math.max( min.z, Math.min( max.z, this.z ) ); + + return this; + + }, + + clampScalar: function () { + + var min = new Vector3(); + var max = new Vector3(); + + return function clampScalar( minVal, maxVal ) { + + min.set( minVal, minVal, minVal ); + max.set( maxVal, maxVal, maxVal ); + + return this.clamp( min, max ); + + }; + + }(), + + clampLength: function ( min, max ) { + + var length = this.length(); + + return this.divideScalar( length || 1 ).multiplyScalar( Math.max( min, Math.min( max, length ) ) ); + + }, + + floor: function () { + + this.x = Math.floor( this.x ); + this.y = Math.floor( this.y ); + this.z = Math.floor( this.z ); + + return this; + + }, + + ceil: function () { + + this.x = Math.ceil( this.x ); + this.y = Math.ceil( this.y ); + this.z = Math.ceil( this.z ); + + return this; + + }, + + round: function () { + + this.x = Math.round( this.x ); + this.y = Math.round( this.y ); + this.z = Math.round( this.z ); + + return this; + + }, + + roundToZero: function () { + + this.x = ( this.x < 0 ) ? Math.ceil( this.x ) : Math.floor( this.x ); + this.y = ( this.y < 0 ) ? Math.ceil( this.y ) : Math.floor( this.y ); + this.z = ( this.z < 0 ) ? Math.ceil( this.z ) : Math.floor( this.z ); + + return this; + + }, + + negate: function () { + + this.x = - this.x; + this.y = - this.y; + this.z = - this.z; + + return this; + + }, + + dot: function ( v ) { + + return this.x * v.x + this.y * v.y + this.z * v.z; + + }, + + // TODO lengthSquared? + + lengthSq: function () { + + return this.x * this.x + this.y * this.y + this.z * this.z; + + }, + + length: function () { + + return Math.sqrt( this.x * this.x + this.y * this.y + this.z * this.z ); + + }, + + manhattanLength: function () { + + return Math.abs( this.x ) + Math.abs( this.y ) + Math.abs( this.z ); + + }, + + normalize: function () { + + return this.divideScalar( this.length() || 1 ); + + }, + + setLength: function ( length ) { + + return this.normalize().multiplyScalar( length ); + + }, + + lerp: function ( v, alpha ) { + + this.x += ( v.x - this.x ) * alpha; + this.y += ( v.y - this.y ) * alpha; + this.z += ( v.z - this.z ) * alpha; + + return this; + + }, + + lerpVectors: function ( v1, v2, alpha ) { + + return this.subVectors( v2, v1 ).multiplyScalar( alpha ).add( v1 ); + + }, + + cross: function ( v, w ) { + + if ( w !== undefined ) { + + console.warn( 'THREE.Vector3: .cross() now only accepts one argument. Use .crossVectors( a, b ) instead.' ); + return this.crossVectors( v, w ); + + } + + return this.crossVectors( this, v ); + + }, + + crossVectors: function ( a, b ) { + + var ax = a.x, ay = a.y, az = a.z; + var bx = b.x, by = b.y, bz = b.z; + + this.x = ay * bz - az * by; + this.y = az * bx - ax * bz; + this.z = ax * by - ay * bx; + + return this; + + }, + + projectOnVector: function ( vector ) { + + var scalar = vector.dot( this ) / vector.lengthSq(); + + return this.copy( vector ).multiplyScalar( scalar ); + + }, + + projectOnPlane: function () { + + var v1 = new Vector3(); + + return function projectOnPlane( planeNormal ) { + + v1.copy( this ).projectOnVector( planeNormal ); + + return this.sub( v1 ); + + }; + + }(), + + reflect: function () { + + // reflect incident vector off plane orthogonal to normal + // normal is assumed to have unit length + + var v1 = new Vector3(); + + return function reflect( normal ) { + + return this.sub( v1.copy( normal ).multiplyScalar( 2 * this.dot( normal ) ) ); + + }; + + }(), + + angleTo: function ( v ) { + + var theta = this.dot( v ) / ( Math.sqrt( this.lengthSq() * v.lengthSq() ) ); + + // clamp, to handle numerical problems + + return Math.acos( _Math.clamp( theta, - 1, 1 ) ); + + }, + + distanceTo: function ( v ) { + + return Math.sqrt( this.distanceToSquared( v ) ); + + }, + + distanceToSquared: function ( v ) { + + var dx = this.x - v.x, dy = this.y - v.y, dz = this.z - v.z; + + return dx * dx + dy * dy + dz * dz; + + }, + + manhattanDistanceTo: function ( v ) { + + return Math.abs( this.x - v.x ) + Math.abs( this.y - v.y ) + Math.abs( this.z - v.z ); + + }, + + setFromSpherical: function ( s ) { + + var sinPhiRadius = Math.sin( s.phi ) * s.radius; + + this.x = sinPhiRadius * Math.sin( s.theta ); + this.y = Math.cos( s.phi ) * s.radius; + this.z = sinPhiRadius * Math.cos( s.theta ); + + return this; + + }, + + setFromCylindrical: function ( c ) { + + this.x = c.radius * Math.sin( c.theta ); + this.y = c.y; + this.z = c.radius * Math.cos( c.theta ); + + return this; + + }, + + setFromMatrixPosition: function ( m ) { + + var e = m.elements; + + this.x = e[ 12 ]; + this.y = e[ 13 ]; + this.z = e[ 14 ]; + + return this; + + }, + + setFromMatrixScale: function ( m ) { + + var sx = this.setFromMatrixColumn( m, 0 ).length(); + var sy = this.setFromMatrixColumn( m, 1 ).length(); + var sz = this.setFromMatrixColumn( m, 2 ).length(); + + this.x = sx; + this.y = sy; + this.z = sz; + + return this; + + }, + + setFromMatrixColumn: function ( m, index ) { + + return this.fromArray( m.elements, index * 4 ); + + }, + + equals: function ( v ) { + + return ( ( v.x === this.x ) && ( v.y === this.y ) && ( v.z === this.z ) ); + + }, + + fromArray: function ( array, offset ) { + + if ( offset === undefined ) offset = 0; + + this.x = array[ offset ]; + this.y = array[ offset + 1 ]; + this.z = array[ offset + 2 ]; + + return this; + + }, + + toArray: function ( array, offset ) { + + if ( array === undefined ) array = []; + if ( offset === undefined ) offset = 0; + + array[ offset ] = this.x; + array[ offset + 1 ] = this.y; + array[ offset + 2 ] = this.z; + + return array; + + }, + + fromBufferAttribute: function ( attribute, index, offset ) { + + if ( offset !== undefined ) { + + console.warn( 'THREE.Vector3: offset has been removed from .fromBufferAttribute().' ); + + } + + this.x = attribute.getX( index ); + this.y = attribute.getY( index ); + this.z = attribute.getZ( index ); + + return this; + + } + + } ); + + /** + * @author alteredq / http://alteredqualia.com/ + * @author WestLangley / http://github.com/WestLangley + * @author bhouston / http://clara.io + * @author tschw + */ + + function Matrix3() { + + this.elements = [ + + 1, 0, 0, + 0, 1, 0, + 0, 0, 1 + + ]; + + if ( arguments.length > 0 ) { + + console.error( 'THREE.Matrix3: the constructor no longer reads arguments. use .set() instead.' ); + + } + + } + + Object.assign( Matrix3.prototype, { + + isMatrix3: true, + + set: function ( n11, n12, n13, n21, n22, n23, n31, n32, n33 ) { + + var te = this.elements; + + te[ 0 ] = n11; te[ 1 ] = n21; te[ 2 ] = n31; + te[ 3 ] = n12; te[ 4 ] = n22; te[ 5 ] = n32; + te[ 6 ] = n13; te[ 7 ] = n23; te[ 8 ] = n33; + + return this; + + }, + + identity: function () { + + this.set( + + 1, 0, 0, + 0, 1, 0, + 0, 0, 1 + + ); + + return this; + + }, + + clone: function () { + + return new this.constructor().fromArray( this.elements ); + + }, + + copy: function ( m ) { + + var te = this.elements; + var me = m.elements; + + te[ 0 ] = me[ 0 ]; te[ 1 ] = me[ 1 ]; te[ 2 ] = me[ 2 ]; + te[ 3 ] = me[ 3 ]; te[ 4 ] = me[ 4 ]; te[ 5 ] = me[ 5 ]; + te[ 6 ] = me[ 6 ]; te[ 7 ] = me[ 7 ]; te[ 8 ] = me[ 8 ]; + + return this; + + }, + + setFromMatrix4: function ( m ) { + + var me = m.elements; + + this.set( + + me[ 0 ], me[ 4 ], me[ 8 ], + me[ 1 ], me[ 5 ], me[ 9 ], + me[ 2 ], me[ 6 ], me[ 10 ] + + ); + + return this; + + }, + + applyToBufferAttribute: function () { + + var v1 = new Vector3(); + + return function applyToBufferAttribute( attribute ) { + + for ( var i = 0, l = attribute.count; i < l; i ++ ) { + + v1.x = attribute.getX( i ); + v1.y = attribute.getY( i ); + v1.z = attribute.getZ( i ); + + v1.applyMatrix3( this ); + + attribute.setXYZ( i, v1.x, v1.y, v1.z ); + + } + + return attribute; + + }; + + }(), + + multiply: function ( m ) { + + return this.multiplyMatrices( this, m ); + + }, + + premultiply: function ( m ) { + + return this.multiplyMatrices( m, this ); + + }, + + multiplyMatrices: function ( a, b ) { + + var ae = a.elements; + var be = b.elements; + var te = this.elements; + + var a11 = ae[ 0 ], a12 = ae[ 3 ], a13 = ae[ 6 ]; + var a21 = ae[ 1 ], a22 = ae[ 4 ], a23 = ae[ 7 ]; + var a31 = ae[ 2 ], a32 = ae[ 5 ], a33 = ae[ 8 ]; + + var b11 = be[ 0 ], b12 = be[ 3 ], b13 = be[ 6 ]; + var b21 = be[ 1 ], b22 = be[ 4 ], b23 = be[ 7 ]; + var b31 = be[ 2 ], b32 = be[ 5 ], b33 = be[ 8 ]; + + te[ 0 ] = a11 * b11 + a12 * b21 + a13 * b31; + te[ 3 ] = a11 * b12 + a12 * b22 + a13 * b32; + te[ 6 ] = a11 * b13 + a12 * b23 + a13 * b33; + + te[ 1 ] = a21 * b11 + a22 * b21 + a23 * b31; + te[ 4 ] = a21 * b12 + a22 * b22 + a23 * b32; + te[ 7 ] = a21 * b13 + a22 * b23 + a23 * b33; + + te[ 2 ] = a31 * b11 + a32 * b21 + a33 * b31; + te[ 5 ] = a31 * b12 + a32 * b22 + a33 * b32; + te[ 8 ] = a31 * b13 + a32 * b23 + a33 * b33; + + return this; + + }, + + multiplyScalar: function ( s ) { + + var te = this.elements; + + te[ 0 ] *= s; te[ 3 ] *= s; te[ 6 ] *= s; + te[ 1 ] *= s; te[ 4 ] *= s; te[ 7 ] *= s; + te[ 2 ] *= s; te[ 5 ] *= s; te[ 8 ] *= s; + + return this; + + }, + + determinant: function () { + + var te = this.elements; + + var a = te[ 0 ], b = te[ 1 ], c = te[ 2 ], + d = te[ 3 ], e = te[ 4 ], f = te[ 5 ], + g = te[ 6 ], h = te[ 7 ], i = te[ 8 ]; + + return a * e * i - a * f * h - b * d * i + b * f * g + c * d * h - c * e * g; + + }, + + getInverse: function ( matrix, throwOnDegenerate ) { + + if ( matrix && matrix.isMatrix4 ) { + + console.error( "THREE.Matrix3: .getInverse() no longer takes a Matrix4 argument." ); + + } + + var me = matrix.elements, + te = this.elements, + + n11 = me[ 0 ], n21 = me[ 1 ], n31 = me[ 2 ], + n12 = me[ 3 ], n22 = me[ 4 ], n32 = me[ 5 ], + n13 = me[ 6 ], n23 = me[ 7 ], n33 = me[ 8 ], + + t11 = n33 * n22 - n32 * n23, + t12 = n32 * n13 - n33 * n12, + t13 = n23 * n12 - n22 * n13, + + det = n11 * t11 + n21 * t12 + n31 * t13; + + if ( det === 0 ) { + + var msg = "THREE.Matrix3: .getInverse() can't invert matrix, determinant is 0"; + + if ( throwOnDegenerate === true ) { + + throw new Error( msg ); + + } else { + + console.warn( msg ); + + } + + return this.identity(); + + } + + var detInv = 1 / det; + + te[ 0 ] = t11 * detInv; + te[ 1 ] = ( n31 * n23 - n33 * n21 ) * detInv; + te[ 2 ] = ( n32 * n21 - n31 * n22 ) * detInv; + + te[ 3 ] = t12 * detInv; + te[ 4 ] = ( n33 * n11 - n31 * n13 ) * detInv; + te[ 5 ] = ( n31 * n12 - n32 * n11 ) * detInv; + + te[ 6 ] = t13 * detInv; + te[ 7 ] = ( n21 * n13 - n23 * n11 ) * detInv; + te[ 8 ] = ( n22 * n11 - n21 * n12 ) * detInv; + + return this; + + }, + + transpose: function () { + + var tmp, m = this.elements; + + tmp = m[ 1 ]; m[ 1 ] = m[ 3 ]; m[ 3 ] = tmp; + tmp = m[ 2 ]; m[ 2 ] = m[ 6 ]; m[ 6 ] = tmp; + tmp = m[ 5 ]; m[ 5 ] = m[ 7 ]; m[ 7 ] = tmp; + + return this; + + }, + + getNormalMatrix: function ( matrix4 ) { + + return this.setFromMatrix4( matrix4 ).getInverse( this ).transpose(); + + }, + + transposeIntoArray: function ( r ) { + + var m = this.elements; + + r[ 0 ] = m[ 0 ]; + r[ 1 ] = m[ 3 ]; + r[ 2 ] = m[ 6 ]; + r[ 3 ] = m[ 1 ]; + r[ 4 ] = m[ 4 ]; + r[ 5 ] = m[ 7 ]; + r[ 6 ] = m[ 2 ]; + r[ 7 ] = m[ 5 ]; + r[ 8 ] = m[ 8 ]; + + return this; + + }, + + setUvTransform: function ( tx, ty, sx, sy, rotation, cx, cy ) { + + var c = Math.cos( rotation ); + var s = Math.sin( rotation ); + + this.set( + sx * c, sx * s, - sx * ( c * cx + s * cy ) + cx + tx, + - sy * s, sy * c, - sy * ( - s * cx + c * cy ) + cy + ty, + 0, 0, 1 + ); + + }, + + scale: function ( sx, sy ) { + + var te = this.elements; + + te[ 0 ] *= sx; te[ 3 ] *= sx; te[ 6 ] *= sx; + te[ 1 ] *= sy; te[ 4 ] *= sy; te[ 7 ] *= sy; + + return this; + + }, + + rotate: function ( theta ) { + + var c = Math.cos( theta ); + var s = Math.sin( theta ); + + var te = this.elements; + + var a11 = te[ 0 ], a12 = te[ 3 ], a13 = te[ 6 ]; + var a21 = te[ 1 ], a22 = te[ 4 ], a23 = te[ 7 ]; + + te[ 0 ] = c * a11 + s * a21; + te[ 3 ] = c * a12 + s * a22; + te[ 6 ] = c * a13 + s * a23; + + te[ 1 ] = - s * a11 + c * a21; + te[ 4 ] = - s * a12 + c * a22; + te[ 7 ] = - s * a13 + c * a23; + + return this; + + }, + + translate: function ( tx, ty ) { + + var te = this.elements; + + te[ 0 ] += tx * te[ 2 ]; te[ 3 ] += tx * te[ 5 ]; te[ 6 ] += tx * te[ 8 ]; + te[ 1 ] += ty * te[ 2 ]; te[ 4 ] += ty * te[ 5 ]; te[ 7 ] += ty * te[ 8 ]; + + return this; + + }, + + equals: function ( matrix ) { + + var te = this.elements; + var me = matrix.elements; + + for ( var i = 0; i < 9; i ++ ) { + + if ( te[ i ] !== me[ i ] ) return false; + + } + + return true; + + }, + + fromArray: function ( array, offset ) { + + if ( offset === undefined ) offset = 0; + + for ( var i = 0; i < 9; i ++ ) { + + this.elements[ i ] = array[ i + offset ]; + + } + + return this; + + }, + + toArray: function ( array, offset ) { + + if ( array === undefined ) array = []; + if ( offset === undefined ) offset = 0; + + var te = this.elements; + + array[ offset ] = te[ 0 ]; + array[ offset + 1 ] = te[ 1 ]; + array[ offset + 2 ] = te[ 2 ]; + + array[ offset + 3 ] = te[ 3 ]; + array[ offset + 4 ] = te[ 4 ]; + array[ offset + 5 ] = te[ 5 ]; + + array[ offset + 6 ] = te[ 6 ]; + array[ offset + 7 ] = te[ 7 ]; + array[ offset + 8 ] = te[ 8 ]; + + return array; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + * @author szimek / https://github.com/szimek/ + */ + + var textureId = 0; + + function Texture( image, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy, encoding ) { + + Object.defineProperty( this, 'id', { value: textureId ++ } ); + + this.uuid = _Math.generateUUID(); + + this.name = ''; + + this.image = image !== undefined ? image : Texture.DEFAULT_IMAGE; + this.mipmaps = []; + + this.mapping = mapping !== undefined ? mapping : Texture.DEFAULT_MAPPING; + + this.wrapS = wrapS !== undefined ? wrapS : ClampToEdgeWrapping; + this.wrapT = wrapT !== undefined ? wrapT : ClampToEdgeWrapping; + + this.magFilter = magFilter !== undefined ? magFilter : LinearFilter; + this.minFilter = minFilter !== undefined ? minFilter : LinearMipMapLinearFilter; + + this.anisotropy = anisotropy !== undefined ? anisotropy : 1; + + this.format = format !== undefined ? format : RGBAFormat; + this.type = type !== undefined ? type : UnsignedByteType; + + this.offset = new Vector2( 0, 0 ); + this.repeat = new Vector2( 1, 1 ); + this.center = new Vector2( 0, 0 ); + this.rotation = 0; + + this.matrixAutoUpdate = true; + this.matrix = new Matrix3(); + + this.generateMipmaps = true; + this.premultiplyAlpha = false; + this.flipY = true; + this.unpackAlignment = 4; // valid values: 1, 2, 4, 8 (see http://www.khronos.org/opengles/sdk/docs/man/xhtml/glPixelStorei.xml) + + // Values of encoding !== THREE.LinearEncoding only supported on map, envMap and emissiveMap. + // + // Also changing the encoding after already used by a Material will not automatically make the Material + // update. You need to explicitly call Material.needsUpdate to trigger it to recompile. + this.encoding = encoding !== undefined ? encoding : LinearEncoding; + + this.version = 0; + this.onUpdate = null; + + } + + Texture.DEFAULT_IMAGE = undefined; + Texture.DEFAULT_MAPPING = UVMapping; + + Texture.prototype = Object.assign( Object.create( EventDispatcher.prototype ), { + + constructor: Texture, + + isTexture: true, + + updateMatrix: function () { + + this.matrix.setUvTransform( this.offset.x, this.offset.y, this.repeat.x, this.repeat.y, this.rotation, this.center.x, this.center.y ); + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( source ) { + + this.name = source.name; + + this.image = source.image; + this.mipmaps = source.mipmaps.slice( 0 ); + + this.mapping = source.mapping; + + this.wrapS = source.wrapS; + this.wrapT = source.wrapT; + + this.magFilter = source.magFilter; + this.minFilter = source.minFilter; + + this.anisotropy = source.anisotropy; + + this.format = source.format; + this.type = source.type; + + this.offset.copy( source.offset ); + this.repeat.copy( source.repeat ); + this.center.copy( source.center ); + this.rotation = source.rotation; + + this.matrixAutoUpdate = source.matrixAutoUpdate; + this.matrix.copy( source.matrix ); + + this.generateMipmaps = source.generateMipmaps; + this.premultiplyAlpha = source.premultiplyAlpha; + this.flipY = source.flipY; + this.unpackAlignment = source.unpackAlignment; + this.encoding = source.encoding; + + return this; + + }, + + toJSON: function ( meta ) { + + var isRootObject = ( meta === undefined || typeof meta === 'string' ); + + if ( ! isRootObject && meta.textures[ this.uuid ] !== undefined ) { + + return meta.textures[ this.uuid ]; + + } + + function getDataURL( image ) { + + var canvas; + + if ( image instanceof HTMLCanvasElement ) { + + canvas = image; + + } else { + + canvas = document.createElementNS( 'http://www.w3.org/1999/xhtml', 'canvas' ); + canvas.width = image.width; + canvas.height = image.height; + + var context = canvas.getContext( '2d' ); + + if ( image instanceof ImageData ) { + + context.putImageData( image, 0, 0 ); + + } else { + + context.drawImage( image, 0, 0, image.width, image.height ); + + } + + } + + if ( canvas.width > 2048 || canvas.height > 2048 ) { + + return canvas.toDataURL( 'image/jpeg', 0.6 ); + + } else { + + return canvas.toDataURL( 'image/png' ); + + } + + } + + var output = { + + metadata: { + version: 4.5, + type: 'Texture', + generator: 'Texture.toJSON' + }, + + uuid: this.uuid, + name: this.name, + + mapping: this.mapping, + + repeat: [ this.repeat.x, this.repeat.y ], + offset: [ this.offset.x, this.offset.y ], + center: [ this.center.x, this.center.y ], + rotation: this.rotation, + + wrap: [ this.wrapS, this.wrapT ], + + format: this.format, + minFilter: this.minFilter, + magFilter: this.magFilter, + anisotropy: this.anisotropy, + + flipY: this.flipY + + }; + + if ( this.image !== undefined ) { + + // TODO: Move to THREE.Image + + var image = this.image; + + if ( image.uuid === undefined ) { + + image.uuid = _Math.generateUUID(); // UGH + + } + + if ( ! isRootObject && meta.images[ image.uuid ] === undefined ) { + + meta.images[ image.uuid ] = { + uuid: image.uuid, + url: getDataURL( image ) + }; + + } + + output.image = image.uuid; + + } + + if ( ! isRootObject ) { + + meta.textures[ this.uuid ] = output; + + } + + return output; + + }, + + dispose: function () { + + this.dispatchEvent( { type: 'dispose' } ); + + }, + + transformUv: function ( uv ) { + + if ( this.mapping !== UVMapping ) return; + + uv.applyMatrix3( this.matrix ); + + if ( uv.x < 0 || uv.x > 1 ) { + + switch ( this.wrapS ) { + + case RepeatWrapping: + + uv.x = uv.x - Math.floor( uv.x ); + break; + + case ClampToEdgeWrapping: + + uv.x = uv.x < 0 ? 0 : 1; + break; + + case MirroredRepeatWrapping: + + if ( Math.abs( Math.floor( uv.x ) % 2 ) === 1 ) { + + uv.x = Math.ceil( uv.x ) - uv.x; + + } else { + + uv.x = uv.x - Math.floor( uv.x ); + + } + break; + + } + + } + + if ( uv.y < 0 || uv.y > 1 ) { + + switch ( this.wrapT ) { + + case RepeatWrapping: + + uv.y = uv.y - Math.floor( uv.y ); + break; + + case ClampToEdgeWrapping: + + uv.y = uv.y < 0 ? 0 : 1; + break; + + case MirroredRepeatWrapping: + + if ( Math.abs( Math.floor( uv.y ) % 2 ) === 1 ) { + + uv.y = Math.ceil( uv.y ) - uv.y; + + } else { + + uv.y = uv.y - Math.floor( uv.y ); + + } + break; + + } + + } + + if ( this.flipY ) { + + uv.y = 1 - uv.y; + + } + + } + + } ); + + Object.defineProperty( Texture.prototype, "needsUpdate", { + + set: function ( value ) { + + if ( value === true ) this.version ++; + + } + + } ); + + /** + * @author supereggbert / http://www.paulbrunt.co.uk/ + * @author philogb / http://blog.thejit.org/ + * @author mikael emtinger / http://gomo.se/ + * @author egraether / http://egraether.com/ + * @author WestLangley / http://github.com/WestLangley + */ + + function Vector4( x, y, z, w ) { + + this.x = x || 0; + this.y = y || 0; + this.z = z || 0; + this.w = ( w !== undefined ) ? w : 1; + + } + + Object.assign( Vector4.prototype, { + + isVector4: true, + + set: function ( x, y, z, w ) { + + this.x = x; + this.y = y; + this.z = z; + this.w = w; + + return this; + + }, + + setScalar: function ( scalar ) { + + this.x = scalar; + this.y = scalar; + this.z = scalar; + this.w = scalar; + + return this; + + }, + + setX: function ( x ) { + + this.x = x; + + return this; + + }, + + setY: function ( y ) { + + this.y = y; + + return this; + + }, + + setZ: function ( z ) { + + this.z = z; + + return this; + + }, + + setW: function ( w ) { + + this.w = w; + + return this; + + }, + + setComponent: function ( index, value ) { + + switch ( index ) { + + case 0: this.x = value; break; + case 1: this.y = value; break; + case 2: this.z = value; break; + case 3: this.w = value; break; + default: throw new Error( 'index is out of range: ' + index ); + + } + + return this; + + }, + + getComponent: function ( index ) { + + switch ( index ) { + + case 0: return this.x; + case 1: return this.y; + case 2: return this.z; + case 3: return this.w; + default: throw new Error( 'index is out of range: ' + index ); + + } + + }, + + clone: function () { + + return new this.constructor( this.x, this.y, this.z, this.w ); + + }, + + copy: function ( v ) { + + this.x = v.x; + this.y = v.y; + this.z = v.z; + this.w = ( v.w !== undefined ) ? v.w : 1; + + return this; + + }, + + add: function ( v, w ) { + + if ( w !== undefined ) { + + console.warn( 'THREE.Vector4: .add() now only accepts one argument. Use .addVectors( a, b ) instead.' ); + return this.addVectors( v, w ); + + } + + this.x += v.x; + this.y += v.y; + this.z += v.z; + this.w += v.w; + + return this; + + }, + + addScalar: function ( s ) { + + this.x += s; + this.y += s; + this.z += s; + this.w += s; + + return this; + + }, + + addVectors: function ( a, b ) { + + this.x = a.x + b.x; + this.y = a.y + b.y; + this.z = a.z + b.z; + this.w = a.w + b.w; + + return this; + + }, + + addScaledVector: function ( v, s ) { + + this.x += v.x * s; + this.y += v.y * s; + this.z += v.z * s; + this.w += v.w * s; + + return this; + + }, + + sub: function ( v, w ) { + + if ( w !== undefined ) { + + console.warn( 'THREE.Vector4: .sub() now only accepts one argument. Use .subVectors( a, b ) instead.' ); + return this.subVectors( v, w ); + + } + + this.x -= v.x; + this.y -= v.y; + this.z -= v.z; + this.w -= v.w; + + return this; + + }, + + subScalar: function ( s ) { + + this.x -= s; + this.y -= s; + this.z -= s; + this.w -= s; + + return this; + + }, + + subVectors: function ( a, b ) { + + this.x = a.x - b.x; + this.y = a.y - b.y; + this.z = a.z - b.z; + this.w = a.w - b.w; + + return this; + + }, + + multiplyScalar: function ( scalar ) { + + this.x *= scalar; + this.y *= scalar; + this.z *= scalar; + this.w *= scalar; + + return this; + + }, + + applyMatrix4: function ( m ) { + + var x = this.x, y = this.y, z = this.z, w = this.w; + var e = m.elements; + + this.x = e[ 0 ] * x + e[ 4 ] * y + e[ 8 ] * z + e[ 12 ] * w; + this.y = e[ 1 ] * x + e[ 5 ] * y + e[ 9 ] * z + e[ 13 ] * w; + this.z = e[ 2 ] * x + e[ 6 ] * y + e[ 10 ] * z + e[ 14 ] * w; + this.w = e[ 3 ] * x + e[ 7 ] * y + e[ 11 ] * z + e[ 15 ] * w; + + return this; + + }, + + divideScalar: function ( scalar ) { + + return this.multiplyScalar( 1 / scalar ); + + }, + + setAxisAngleFromQuaternion: function ( q ) { + + // http://www.euclideanspace.com/maths/geometry/rotations/conversions/quaternionToAngle/index.htm + + // q is assumed to be normalized + + this.w = 2 * Math.acos( q.w ); + + var s = Math.sqrt( 1 - q.w * q.w ); + + if ( s < 0.0001 ) { + + this.x = 1; + this.y = 0; + this.z = 0; + + } else { + + this.x = q.x / s; + this.y = q.y / s; + this.z = q.z / s; + + } + + return this; + + }, + + setAxisAngleFromRotationMatrix: function ( m ) { + + // http://www.euclideanspace.com/maths/geometry/rotations/conversions/matrixToAngle/index.htm + + // assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled) + + var angle, x, y, z, // variables for result + epsilon = 0.01, // margin to allow for rounding errors + epsilon2 = 0.1, // margin to distinguish between 0 and 180 degrees + + te = m.elements, + + m11 = te[ 0 ], m12 = te[ 4 ], m13 = te[ 8 ], + m21 = te[ 1 ], m22 = te[ 5 ], m23 = te[ 9 ], + m31 = te[ 2 ], m32 = te[ 6 ], m33 = te[ 10 ]; + + if ( ( Math.abs( m12 - m21 ) < epsilon ) && + ( Math.abs( m13 - m31 ) < epsilon ) && + ( Math.abs( m23 - m32 ) < epsilon ) ) { + + // singularity found + // first check for identity matrix which must have +1 for all terms + // in leading diagonal and zero in other terms + + if ( ( Math.abs( m12 + m21 ) < epsilon2 ) && + ( Math.abs( m13 + m31 ) < epsilon2 ) && + ( Math.abs( m23 + m32 ) < epsilon2 ) && + ( Math.abs( m11 + m22 + m33 - 3 ) < epsilon2 ) ) { + + // this singularity is identity matrix so angle = 0 + + this.set( 1, 0, 0, 0 ); + + return this; // zero angle, arbitrary axis + + } + + // otherwise this singularity is angle = 180 + + angle = Math.PI; + + var xx = ( m11 + 1 ) / 2; + var yy = ( m22 + 1 ) / 2; + var zz = ( m33 + 1 ) / 2; + var xy = ( m12 + m21 ) / 4; + var xz = ( m13 + m31 ) / 4; + var yz = ( m23 + m32 ) / 4; + + if ( ( xx > yy ) && ( xx > zz ) ) { + + // m11 is the largest diagonal term + + if ( xx < epsilon ) { + + x = 0; + y = 0.707106781; + z = 0.707106781; + + } else { + + x = Math.sqrt( xx ); + y = xy / x; + z = xz / x; + + } + + } else if ( yy > zz ) { + + // m22 is the largest diagonal term + + if ( yy < epsilon ) { + + x = 0.707106781; + y = 0; + z = 0.707106781; + + } else { + + y = Math.sqrt( yy ); + x = xy / y; + z = yz / y; + + } + + } else { + + // m33 is the largest diagonal term so base result on this + + if ( zz < epsilon ) { + + x = 0.707106781; + y = 0.707106781; + z = 0; + + } else { + + z = Math.sqrt( zz ); + x = xz / z; + y = yz / z; + + } + + } + + this.set( x, y, z, angle ); + + return this; // return 180 deg rotation + + } + + // as we have reached here there are no singularities so we can handle normally + + var s = Math.sqrt( ( m32 - m23 ) * ( m32 - m23 ) + + ( m13 - m31 ) * ( m13 - m31 ) + + ( m21 - m12 ) * ( m21 - m12 ) ); // used to normalize + + if ( Math.abs( s ) < 0.001 ) s = 1; + + // prevent divide by zero, should not happen if matrix is orthogonal and should be + // caught by singularity test above, but I've left it in just in case + + this.x = ( m32 - m23 ) / s; + this.y = ( m13 - m31 ) / s; + this.z = ( m21 - m12 ) / s; + this.w = Math.acos( ( m11 + m22 + m33 - 1 ) / 2 ); + + return this; + + }, + + min: function ( v ) { + + this.x = Math.min( this.x, v.x ); + this.y = Math.min( this.y, v.y ); + this.z = Math.min( this.z, v.z ); + this.w = Math.min( this.w, v.w ); + + return this; + + }, + + max: function ( v ) { + + this.x = Math.max( this.x, v.x ); + this.y = Math.max( this.y, v.y ); + this.z = Math.max( this.z, v.z ); + this.w = Math.max( this.w, v.w ); + + return this; + + }, + + clamp: function ( min, max ) { + + // assumes min < max, componentwise + + this.x = Math.max( min.x, Math.min( max.x, this.x ) ); + this.y = Math.max( min.y, Math.min( max.y, this.y ) ); + this.z = Math.max( min.z, Math.min( max.z, this.z ) ); + this.w = Math.max( min.w, Math.min( max.w, this.w ) ); + + return this; + + }, + + clampScalar: function () { + + var min, max; + + return function clampScalar( minVal, maxVal ) { + + if ( min === undefined ) { + + min = new Vector4(); + max = new Vector4(); + + } + + min.set( minVal, minVal, minVal, minVal ); + max.set( maxVal, maxVal, maxVal, maxVal ); + + return this.clamp( min, max ); + + }; + + }(), + + clampLength: function ( min, max ) { + + var length = this.length(); + + return this.divideScalar( length || 1 ).multiplyScalar( Math.max( min, Math.min( max, length ) ) ); + + }, + + floor: function () { + + this.x = Math.floor( this.x ); + this.y = Math.floor( this.y ); + this.z = Math.floor( this.z ); + this.w = Math.floor( this.w ); + + return this; + + }, + + ceil: function () { + + this.x = Math.ceil( this.x ); + this.y = Math.ceil( this.y ); + this.z = Math.ceil( this.z ); + this.w = Math.ceil( this.w ); + + return this; + + }, + + round: function () { + + this.x = Math.round( this.x ); + this.y = Math.round( this.y ); + this.z = Math.round( this.z ); + this.w = Math.round( this.w ); + + return this; + + }, + + roundToZero: function () { + + this.x = ( this.x < 0 ) ? Math.ceil( this.x ) : Math.floor( this.x ); + this.y = ( this.y < 0 ) ? Math.ceil( this.y ) : Math.floor( this.y ); + this.z = ( this.z < 0 ) ? Math.ceil( this.z ) : Math.floor( this.z ); + this.w = ( this.w < 0 ) ? Math.ceil( this.w ) : Math.floor( this.w ); + + return this; + + }, + + negate: function () { + + this.x = - this.x; + this.y = - this.y; + this.z = - this.z; + this.w = - this.w; + + return this; + + }, + + dot: function ( v ) { + + return this.x * v.x + this.y * v.y + this.z * v.z + this.w * v.w; + + }, + + lengthSq: function () { + + return this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w; + + }, + + length: function () { + + return Math.sqrt( this.x * this.x + this.y * this.y + this.z * this.z + this.w * this.w ); + + }, + + manhattanLength: function () { + + return Math.abs( this.x ) + Math.abs( this.y ) + Math.abs( this.z ) + Math.abs( this.w ); + + }, + + normalize: function () { + + return this.divideScalar( this.length() || 1 ); + + }, + + setLength: function ( length ) { + + return this.normalize().multiplyScalar( length ); + + }, + + lerp: function ( v, alpha ) { + + this.x += ( v.x - this.x ) * alpha; + this.y += ( v.y - this.y ) * alpha; + this.z += ( v.z - this.z ) * alpha; + this.w += ( v.w - this.w ) * alpha; + + return this; + + }, + + lerpVectors: function ( v1, v2, alpha ) { + + return this.subVectors( v2, v1 ).multiplyScalar( alpha ).add( v1 ); + + }, + + equals: function ( v ) { + + return ( ( v.x === this.x ) && ( v.y === this.y ) && ( v.z === this.z ) && ( v.w === this.w ) ); + + }, + + fromArray: function ( array, offset ) { + + if ( offset === undefined ) offset = 0; + + this.x = array[ offset ]; + this.y = array[ offset + 1 ]; + this.z = array[ offset + 2 ]; + this.w = array[ offset + 3 ]; + + return this; + + }, + + toArray: function ( array, offset ) { + + if ( array === undefined ) array = []; + if ( offset === undefined ) offset = 0; + + array[ offset ] = this.x; + array[ offset + 1 ] = this.y; + array[ offset + 2 ] = this.z; + array[ offset + 3 ] = this.w; + + return array; + + }, + + fromBufferAttribute: function ( attribute, index, offset ) { + + if ( offset !== undefined ) { + + console.warn( 'THREE.Vector4: offset has been removed from .fromBufferAttribute().' ); + + } + + this.x = attribute.getX( index ); + this.y = attribute.getY( index ); + this.z = attribute.getZ( index ); + this.w = attribute.getW( index ); + + return this; + + } + + } ); + + /** + * @author szimek / https://github.com/szimek/ + * @author alteredq / http://alteredqualia.com/ + * @author Marius Kintel / https://github.com/kintel + */ + + /* + In options, we can specify: + * Texture parameters for an auto-generated target texture + * depthBuffer/stencilBuffer: Booleans to indicate if we should generate these buffers + */ + function WebGLRenderTarget( width, height, options ) { + + this.width = width; + this.height = height; + + this.scissor = new Vector4( 0, 0, width, height ); + this.scissorTest = false; + + this.viewport = new Vector4( 0, 0, width, height ); + + options = options || {}; + + if ( options.minFilter === undefined ) options.minFilter = LinearFilter; + + this.texture = new Texture( undefined, undefined, options.wrapS, options.wrapT, options.magFilter, options.minFilter, options.format, options.type, options.anisotropy, options.encoding ); + + this.texture.generateMipmaps = options.generateMipmaps !== undefined ? options.generateMipmaps : true; + + this.depthBuffer = options.depthBuffer !== undefined ? options.depthBuffer : true; + this.stencilBuffer = options.stencilBuffer !== undefined ? options.stencilBuffer : true; + this.depthTexture = options.depthTexture !== undefined ? options.depthTexture : null; + + } + + WebGLRenderTarget.prototype = Object.assign( Object.create( EventDispatcher.prototype ), { + + constructor: WebGLRenderTarget, + + isWebGLRenderTarget: true, + + setSize: function ( width, height ) { + + if ( this.width !== width || this.height !== height ) { + + this.width = width; + this.height = height; + + this.dispose(); + + } + + this.viewport.set( 0, 0, width, height ); + this.scissor.set( 0, 0, width, height ); + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( source ) { + + this.width = source.width; + this.height = source.height; + + this.viewport.copy( source.viewport ); + + this.texture = source.texture.clone(); + + this.depthBuffer = source.depthBuffer; + this.stencilBuffer = source.stencilBuffer; + this.depthTexture = source.depthTexture; + + return this; + + }, + + dispose: function () { + + this.dispatchEvent( { type: 'dispose' } ); + + } + + } ); + + /** + * @author alteredq / http://alteredqualia.com + */ + + function WebGLRenderTargetCube( width, height, options ) { + + WebGLRenderTarget.call( this, width, height, options ); + + this.activeCubeFace = 0; // PX 0, NX 1, PY 2, NY 3, PZ 4, NZ 5 + this.activeMipMapLevel = 0; + + } + + WebGLRenderTargetCube.prototype = Object.create( WebGLRenderTarget.prototype ); + WebGLRenderTargetCube.prototype.constructor = WebGLRenderTargetCube; + + WebGLRenderTargetCube.prototype.isWebGLRenderTargetCube = true; + + /** + * @author alteredq / http://alteredqualia.com/ + */ + + function DataTexture( data, width, height, format, type, mapping, wrapS, wrapT, magFilter, minFilter, anisotropy, encoding ) { + + Texture.call( this, null, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy, encoding ); + + this.image = { data: data, width: width, height: height }; + + this.magFilter = magFilter !== undefined ? magFilter : NearestFilter; + this.minFilter = minFilter !== undefined ? minFilter : NearestFilter; + + this.generateMipmaps = false; + this.flipY = false; + this.unpackAlignment = 1; + + } + + DataTexture.prototype = Object.create( Texture.prototype ); + DataTexture.prototype.constructor = DataTexture; + + DataTexture.prototype.isDataTexture = true; + + /** + * @author bhouston / http://clara.io + * @author WestLangley / http://github.com/WestLangley + */ + + function Box3( min, max ) { + + this.min = ( min !== undefined ) ? min : new Vector3( + Infinity, + Infinity, + Infinity ); + this.max = ( max !== undefined ) ? max : new Vector3( - Infinity, - Infinity, - Infinity ); + + } + + Object.assign( Box3.prototype, { + + isBox3: true, + + set: function ( min, max ) { + + this.min.copy( min ); + this.max.copy( max ); + + return this; + + }, + + setFromArray: function ( array ) { + + var minX = + Infinity; + var minY = + Infinity; + var minZ = + Infinity; + + var maxX = - Infinity; + var maxY = - Infinity; + var maxZ = - Infinity; + + for ( var i = 0, l = array.length; i < l; i += 3 ) { + + var x = array[ i ]; + var y = array[ i + 1 ]; + var z = array[ i + 2 ]; + + if ( x < minX ) minX = x; + if ( y < minY ) minY = y; + if ( z < minZ ) minZ = z; + + if ( x > maxX ) maxX = x; + if ( y > maxY ) maxY = y; + if ( z > maxZ ) maxZ = z; + + } + + this.min.set( minX, minY, minZ ); + this.max.set( maxX, maxY, maxZ ); + + return this; + + }, + + setFromBufferAttribute: function ( attribute ) { + + var minX = + Infinity; + var minY = + Infinity; + var minZ = + Infinity; + + var maxX = - Infinity; + var maxY = - Infinity; + var maxZ = - Infinity; + + for ( var i = 0, l = attribute.count; i < l; i ++ ) { + + var x = attribute.getX( i ); + var y = attribute.getY( i ); + var z = attribute.getZ( i ); + + if ( x < minX ) minX = x; + if ( y < minY ) minY = y; + if ( z < minZ ) minZ = z; + + if ( x > maxX ) maxX = x; + if ( y > maxY ) maxY = y; + if ( z > maxZ ) maxZ = z; + + } + + this.min.set( minX, minY, minZ ); + this.max.set( maxX, maxY, maxZ ); + + return this; + + }, + + setFromPoints: function ( points ) { + + this.makeEmpty(); + + for ( var i = 0, il = points.length; i < il; i ++ ) { + + this.expandByPoint( points[ i ] ); + + } + + return this; + + }, + + setFromCenterAndSize: function () { + + var v1 = new Vector3(); + + return function setFromCenterAndSize( center, size ) { + + var halfSize = v1.copy( size ).multiplyScalar( 0.5 ); + + this.min.copy( center ).sub( halfSize ); + this.max.copy( center ).add( halfSize ); + + return this; + + }; + + }(), + + setFromObject: function ( object ) { + + this.makeEmpty(); + + return this.expandByObject( object ); + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( box ) { + + this.min.copy( box.min ); + this.max.copy( box.max ); + + return this; + + }, + + makeEmpty: function () { + + this.min.x = this.min.y = this.min.z = + Infinity; + this.max.x = this.max.y = this.max.z = - Infinity; + + return this; + + }, + + isEmpty: function () { + + // this is a more robust check for empty than ( volume <= 0 ) because volume can get positive with two negative axes + + return ( this.max.x < this.min.x ) || ( this.max.y < this.min.y ) || ( this.max.z < this.min.z ); + + }, + + getCenter: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Box3: .getCenter() target is now required' ); + target = new Vector3(); + + } + + return this.isEmpty() ? target.set( 0, 0, 0 ) : target.addVectors( this.min, this.max ).multiplyScalar( 0.5 ); + + }, + + getSize: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Box3: .getSize() target is now required' ); + target = new Vector3(); + + } + + return this.isEmpty() ? target.set( 0, 0, 0 ) : target.subVectors( this.max, this.min ); + + }, + + expandByPoint: function ( point ) { + + this.min.min( point ); + this.max.max( point ); + + return this; + + }, + + expandByVector: function ( vector ) { + + this.min.sub( vector ); + this.max.add( vector ); + + return this; + + }, + + expandByScalar: function ( scalar ) { + + this.min.addScalar( - scalar ); + this.max.addScalar( scalar ); + + return this; + + }, + + expandByObject: function () { + + // Computes the world-axis-aligned bounding box of an object (including its children), + // accounting for both the object's, and children's, world transforms + + var scope, i, l; + + var v1 = new Vector3(); + + function traverse( node ) { + + var geometry = node.geometry; + + if ( geometry !== undefined ) { + + if ( geometry.isGeometry ) { + + var vertices = geometry.vertices; + + for ( i = 0, l = vertices.length; i < l; i ++ ) { + + v1.copy( vertices[ i ] ); + v1.applyMatrix4( node.matrixWorld ); + + scope.expandByPoint( v1 ); + + } + + } else if ( geometry.isBufferGeometry ) { + + var attribute = geometry.attributes.position; + + if ( attribute !== undefined ) { + + for ( i = 0, l = attribute.count; i < l; i ++ ) { + + v1.fromBufferAttribute( attribute, i ).applyMatrix4( node.matrixWorld ); + + scope.expandByPoint( v1 ); + + } + + } + + } + + } + + } + + return function expandByObject( object ) { + + scope = this; + + object.updateMatrixWorld( true ); + + object.traverse( traverse ); + + return this; + + }; + + }(), + + containsPoint: function ( point ) { + + return point.x < this.min.x || point.x > this.max.x || + point.y < this.min.y || point.y > this.max.y || + point.z < this.min.z || point.z > this.max.z ? false : true; + + }, + + containsBox: function ( box ) { + + return this.min.x <= box.min.x && box.max.x <= this.max.x && + this.min.y <= box.min.y && box.max.y <= this.max.y && + this.min.z <= box.min.z && box.max.z <= this.max.z; + + }, + + getParameter: function ( point, target ) { + + // This can potentially have a divide by zero if the box + // has a size dimension of 0. + + if ( target === undefined ) { + + console.warn( 'THREE.Box3: .getParameter() target is now required' ); + target = new Vector3(); + + } + + return target.set( + ( point.x - this.min.x ) / ( this.max.x - this.min.x ), + ( point.y - this.min.y ) / ( this.max.y - this.min.y ), + ( point.z - this.min.z ) / ( this.max.z - this.min.z ) + ); + + }, + + intersectsBox: function ( box ) { + + // using 6 splitting planes to rule out intersections. + return box.max.x < this.min.x || box.min.x > this.max.x || + box.max.y < this.min.y || box.min.y > this.max.y || + box.max.z < this.min.z || box.min.z > this.max.z ? false : true; + + }, + + intersectsSphere: ( function () { + + var closestPoint = new Vector3(); + + return function intersectsSphere( sphere ) { + + // Find the point on the AABB closest to the sphere center. + this.clampPoint( sphere.center, closestPoint ); + + // If that point is inside the sphere, the AABB and sphere intersect. + return closestPoint.distanceToSquared( sphere.center ) <= ( sphere.radius * sphere.radius ); + + }; + + } )(), + + intersectsPlane: function ( plane ) { + + // We compute the minimum and maximum dot product values. If those values + // are on the same side (back or front) of the plane, then there is no intersection. + + var min, max; + + if ( plane.normal.x > 0 ) { + + min = plane.normal.x * this.min.x; + max = plane.normal.x * this.max.x; + + } else { + + min = plane.normal.x * this.max.x; + max = plane.normal.x * this.min.x; + + } + + if ( plane.normal.y > 0 ) { + + min += plane.normal.y * this.min.y; + max += plane.normal.y * this.max.y; + + } else { + + min += plane.normal.y * this.max.y; + max += plane.normal.y * this.min.y; + + } + + if ( plane.normal.z > 0 ) { + + min += plane.normal.z * this.min.z; + max += plane.normal.z * this.max.z; + + } else { + + min += plane.normal.z * this.max.z; + max += plane.normal.z * this.min.z; + + } + + return ( min <= plane.constant && max >= plane.constant ); + + }, + + intersectsTriangle: ( function () { + + // triangle centered vertices + var v0 = new Vector3(); + var v1 = new Vector3(); + var v2 = new Vector3(); + + // triangle edge vectors + var f0 = new Vector3(); + var f1 = new Vector3(); + var f2 = new Vector3(); + + var testAxis = new Vector3(); + + var center = new Vector3(); + var extents = new Vector3(); + + var triangleNormal = new Vector3(); + + function satForAxes( axes ) { + + var i, j; + + for ( i = 0, j = axes.length - 3; i <= j; i += 3 ) { + + testAxis.fromArray( axes, i ); + // project the aabb onto the seperating axis + var r = extents.x * Math.abs( testAxis.x ) + extents.y * Math.abs( testAxis.y ) + extents.z * Math.abs( testAxis.z ); + // project all 3 vertices of the triangle onto the seperating axis + var p0 = v0.dot( testAxis ); + var p1 = v1.dot( testAxis ); + var p2 = v2.dot( testAxis ); + // actual test, basically see if either of the most extreme of the triangle points intersects r + if ( Math.max( - Math.max( p0, p1, p2 ), Math.min( p0, p1, p2 ) ) > r ) { + + // points of the projected triangle are outside the projected half-length of the aabb + // the axis is seperating and we can exit + return false; + + } + + } + + return true; + + } + + return function intersectsTriangle( triangle ) { + + if ( this.isEmpty() ) { + + return false; + + } + + // compute box center and extents + this.getCenter( center ); + extents.subVectors( this.max, center ); + + // translate triangle to aabb origin + v0.subVectors( triangle.a, center ); + v1.subVectors( triangle.b, center ); + v2.subVectors( triangle.c, center ); + + // compute edge vectors for triangle + f0.subVectors( v1, v0 ); + f1.subVectors( v2, v1 ); + f2.subVectors( v0, v2 ); + + // test against axes that are given by cross product combinations of the edges of the triangle and the edges of the aabb + // make an axis testing of each of the 3 sides of the aabb against each of the 3 sides of the triangle = 9 axis of separation + // axis_ij = u_i x f_j (u0, u1, u2 = face normals of aabb = x,y,z axes vectors since aabb is axis aligned) + var axes = [ + 0, - f0.z, f0.y, 0, - f1.z, f1.y, 0, - f2.z, f2.y, + f0.z, 0, - f0.x, f1.z, 0, - f1.x, f2.z, 0, - f2.x, + - f0.y, f0.x, 0, - f1.y, f1.x, 0, - f2.y, f2.x, 0 + ]; + if ( ! satForAxes( axes ) ) { + + return false; + + } + + // test 3 face normals from the aabb + axes = [ 1, 0, 0, 0, 1, 0, 0, 0, 1 ]; + if ( ! satForAxes( axes ) ) { + + return false; + + } + + // finally testing the face normal of the triangle + // use already existing triangle edge vectors here + triangleNormal.crossVectors( f0, f1 ); + axes = [ triangleNormal.x, triangleNormal.y, triangleNormal.z ]; + return satForAxes( axes ); + + }; + + } )(), + + clampPoint: function ( point, target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Box3: .clampPoint() target is now required' ); + target = new Vector3(); + + } + + return target.copy( point ).clamp( this.min, this.max ); + + }, + + distanceToPoint: function () { + + var v1 = new Vector3(); + + return function distanceToPoint( point ) { + + var clampedPoint = v1.copy( point ).clamp( this.min, this.max ); + return clampedPoint.sub( point ).length(); + + }; + + }(), + + getBoundingSphere: function () { + + var v1 = new Vector3(); + + return function getBoundingSphere( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Box3: .getBoundingSphere() target is now required' ); + target = new Sphere(); + + } + + this.getCenter( target.center ); + + target.radius = this.getSize( v1 ).length() * 0.5; + + return target; + + }; + + }(), + + intersect: function ( box ) { + + this.min.max( box.min ); + this.max.min( box.max ); + + // ensure that if there is no overlap, the result is fully empty, not slightly empty with non-inf/+inf values that will cause subsequence intersects to erroneously return valid values. + if ( this.isEmpty() ) this.makeEmpty(); + + return this; + + }, + + union: function ( box ) { + + this.min.min( box.min ); + this.max.max( box.max ); + + return this; + + }, + + applyMatrix4: function ( matrix ) { + + // transform of empty box is an empty box. + if ( this.isEmpty( ) ) return this; + + var m = matrix.elements; + + var xax = m[ 0 ] * this.min.x, xay = m[ 1 ] * this.min.x, xaz = m[ 2 ] * this.min.x; + var xbx = m[ 0 ] * this.max.x, xby = m[ 1 ] * this.max.x, xbz = m[ 2 ] * this.max.x; + var yax = m[ 4 ] * this.min.y, yay = m[ 5 ] * this.min.y, yaz = m[ 6 ] * this.min.y; + var ybx = m[ 4 ] * this.max.y, yby = m[ 5 ] * this.max.y, ybz = m[ 6 ] * this.max.y; + var zax = m[ 8 ] * this.min.z, zay = m[ 9 ] * this.min.z, zaz = m[ 10 ] * this.min.z; + var zbx = m[ 8 ] * this.max.z, zby = m[ 9 ] * this.max.z, zbz = m[ 10 ] * this.max.z; + + this.min.x = Math.min( xax, xbx ) + Math.min( yax, ybx ) + Math.min( zax, zbx ) + m[ 12 ]; + this.min.y = Math.min( xay, xby ) + Math.min( yay, yby ) + Math.min( zay, zby ) + m[ 13 ]; + this.min.z = Math.min( xaz, xbz ) + Math.min( yaz, ybz ) + Math.min( zaz, zbz ) + m[ 14 ]; + this.max.x = Math.max( xax, xbx ) + Math.max( yax, ybx ) + Math.max( zax, zbx ) + m[ 12 ]; + this.max.y = Math.max( xay, xby ) + Math.max( yay, yby ) + Math.max( zay, zby ) + m[ 13 ]; + this.max.z = Math.max( xaz, xbz ) + Math.max( yaz, ybz ) + Math.max( zaz, zbz ) + m[ 14 ]; + + return this; + + }, + + translate: function ( offset ) { + + this.min.add( offset ); + this.max.add( offset ); + + return this; + + }, + + equals: function ( box ) { + + return box.min.equals( this.min ) && box.max.equals( this.max ); + + } + + } ); + + /** + * @author bhouston / http://clara.io + * @author mrdoob / http://mrdoob.com/ + */ + + function Sphere( center, radius ) { + + this.center = ( center !== undefined ) ? center : new Vector3(); + this.radius = ( radius !== undefined ) ? radius : 0; + + } + + Object.assign( Sphere.prototype, { + + set: function ( center, radius ) { + + this.center.copy( center ); + this.radius = radius; + + return this; + + }, + + setFromPoints: function () { + + var box = new Box3(); + + return function setFromPoints( points, optionalCenter ) { + + var center = this.center; + + if ( optionalCenter !== undefined ) { + + center.copy( optionalCenter ); + + } else { + + box.setFromPoints( points ).getCenter( center ); + + } + + var maxRadiusSq = 0; + + for ( var i = 0, il = points.length; i < il; i ++ ) { + + maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( points[ i ] ) ); + + } + + this.radius = Math.sqrt( maxRadiusSq ); + + return this; + + }; + + }(), + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( sphere ) { + + this.center.copy( sphere.center ); + this.radius = sphere.radius; + + return this; + + }, + + empty: function () { + + return ( this.radius <= 0 ); + + }, + + containsPoint: function ( point ) { + + return ( point.distanceToSquared( this.center ) <= ( this.radius * this.radius ) ); + + }, + + distanceToPoint: function ( point ) { + + return ( point.distanceTo( this.center ) - this.radius ); + + }, + + intersectsSphere: function ( sphere ) { + + var radiusSum = this.radius + sphere.radius; + + return sphere.center.distanceToSquared( this.center ) <= ( radiusSum * radiusSum ); + + }, + + intersectsBox: function ( box ) { + + return box.intersectsSphere( this ); + + }, + + intersectsPlane: function ( plane ) { + + return Math.abs( plane.distanceToPoint( this.center ) ) <= this.radius; + + }, + + clampPoint: function ( point, target ) { + + var deltaLengthSq = this.center.distanceToSquared( point ); + + if ( target === undefined ) { + + console.warn( 'THREE.Sphere: .clampPoint() target is now required' ); + target = new Vector3(); + + } + + target.copy( point ); + + if ( deltaLengthSq > ( this.radius * this.radius ) ) { + + target.sub( this.center ).normalize(); + target.multiplyScalar( this.radius ).add( this.center ); + + } + + return target; + + }, + + getBoundingBox: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Sphere: .getBoundingBox() target is now required' ); + target = new Box3(); + + } + + target.set( this.center, this.center ); + target.expandByScalar( this.radius ); + + return target; + + }, + + applyMatrix4: function ( matrix ) { + + this.center.applyMatrix4( matrix ); + this.radius = this.radius * matrix.getMaxScaleOnAxis(); + + return this; + + }, + + translate: function ( offset ) { + + this.center.add( offset ); + + return this; + + }, + + equals: function ( sphere ) { + + return sphere.center.equals( this.center ) && ( sphere.radius === this.radius ); + + } + + } ); + + /** + * @author bhouston / http://clara.io + */ + + function Plane( normal, constant ) { + + // normal is assumed to be normalized + + this.normal = ( normal !== undefined ) ? normal : new Vector3( 1, 0, 0 ); + this.constant = ( constant !== undefined ) ? constant : 0; + + } + + Object.assign( Plane.prototype, { + + set: function ( normal, constant ) { + + this.normal.copy( normal ); + this.constant = constant; + + return this; + + }, + + setComponents: function ( x, y, z, w ) { + + this.normal.set( x, y, z ); + this.constant = w; + + return this; + + }, + + setFromNormalAndCoplanarPoint: function ( normal, point ) { + + this.normal.copy( normal ); + this.constant = - point.dot( this.normal ); + + return this; + + }, + + setFromCoplanarPoints: function () { + + var v1 = new Vector3(); + var v2 = new Vector3(); + + return function setFromCoplanarPoints( a, b, c ) { + + var normal = v1.subVectors( c, b ).cross( v2.subVectors( a, b ) ).normalize(); + + // Q: should an error be thrown if normal is zero (e.g. degenerate plane)? + + this.setFromNormalAndCoplanarPoint( normal, a ); + + return this; + + }; + + }(), + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( plane ) { + + this.normal.copy( plane.normal ); + this.constant = plane.constant; + + return this; + + }, + + normalize: function () { + + // Note: will lead to a divide by zero if the plane is invalid. + + var inverseNormalLength = 1.0 / this.normal.length(); + this.normal.multiplyScalar( inverseNormalLength ); + this.constant *= inverseNormalLength; + + return this; + + }, + + negate: function () { + + this.constant *= - 1; + this.normal.negate(); + + return this; + + }, + + distanceToPoint: function ( point ) { + + return this.normal.dot( point ) + this.constant; + + }, + + distanceToSphere: function ( sphere ) { + + return this.distanceToPoint( sphere.center ) - sphere.radius; + + }, + + projectPoint: function ( point, target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Plane: .projectPoint() target is now required' ); + target = new Vector3(); + + } + + return target.copy( this.normal ).multiplyScalar( - this.distanceToPoint( point ) ).add( point ); + + }, + + intersectLine: function () { + + var v1 = new Vector3(); + + return function intersectLine( line, target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Plane: .intersectLine() target is now required' ); + target = new Vector3(); + + } + + var direction = line.delta( v1 ); + + var denominator = this.normal.dot( direction ); + + if ( denominator === 0 ) { + + // line is coplanar, return origin + if ( this.distanceToPoint( line.start ) === 0 ) { + + return target.copy( line.start ); + + } + + // Unsure if this is the correct method to handle this case. + return undefined; + + } + + var t = - ( line.start.dot( this.normal ) + this.constant ) / denominator; + + if ( t < 0 || t > 1 ) { + + return undefined; + + } + + return target.copy( direction ).multiplyScalar( t ).add( line.start ); + + }; + + }(), + + intersectsLine: function ( line ) { + + // Note: this tests if a line intersects the plane, not whether it (or its end-points) are coplanar with it. + + var startSign = this.distanceToPoint( line.start ); + var endSign = this.distanceToPoint( line.end ); + + return ( startSign < 0 && endSign > 0 ) || ( endSign < 0 && startSign > 0 ); + + }, + + intersectsBox: function ( box ) { + + return box.intersectsPlane( this ); + + }, + + intersectsSphere: function ( sphere ) { + + return sphere.intersectsPlane( this ); + + }, + + coplanarPoint: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Plane: .coplanarPoint() target is now required' ); + target = new Vector3(); + + } + + return target.copy( this.normal ).multiplyScalar( - this.constant ); + + }, + + applyMatrix4: function () { + + var v1 = new Vector3(); + var m1 = new Matrix3(); + + return function applyMatrix4( matrix, optionalNormalMatrix ) { + + var normalMatrix = optionalNormalMatrix || m1.getNormalMatrix( matrix ); + + var referencePoint = this.coplanarPoint( v1 ).applyMatrix4( matrix ); + + var normal = this.normal.applyMatrix3( normalMatrix ).normalize(); + + this.constant = - referencePoint.dot( normal ); + + return this; + + }; + + }(), + + translate: function ( offset ) { + + this.constant -= offset.dot( this.normal ); + + return this; + + }, + + equals: function ( plane ) { + + return plane.normal.equals( this.normal ) && ( plane.constant === this.constant ); + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + * @author bhouston / http://clara.io + */ + + function Frustum( p0, p1, p2, p3, p4, p5 ) { + + this.planes = [ + + ( p0 !== undefined ) ? p0 : new Plane(), + ( p1 !== undefined ) ? p1 : new Plane(), + ( p2 !== undefined ) ? p2 : new Plane(), + ( p3 !== undefined ) ? p3 : new Plane(), + ( p4 !== undefined ) ? p4 : new Plane(), + ( p5 !== undefined ) ? p5 : new Plane() + + ]; + + } + + Object.assign( Frustum.prototype, { + + set: function ( p0, p1, p2, p3, p4, p5 ) { + + var planes = this.planes; + + planes[ 0 ].copy( p0 ); + planes[ 1 ].copy( p1 ); + planes[ 2 ].copy( p2 ); + planes[ 3 ].copy( p3 ); + planes[ 4 ].copy( p4 ); + planes[ 5 ].copy( p5 ); + + return this; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( frustum ) { + + var planes = this.planes; + + for ( var i = 0; i < 6; i ++ ) { + + planes[ i ].copy( frustum.planes[ i ] ); + + } + + return this; + + }, + + setFromMatrix: function ( m ) { + + var planes = this.planes; + var me = m.elements; + var me0 = me[ 0 ], me1 = me[ 1 ], me2 = me[ 2 ], me3 = me[ 3 ]; + var me4 = me[ 4 ], me5 = me[ 5 ], me6 = me[ 6 ], me7 = me[ 7 ]; + var me8 = me[ 8 ], me9 = me[ 9 ], me10 = me[ 10 ], me11 = me[ 11 ]; + var me12 = me[ 12 ], me13 = me[ 13 ], me14 = me[ 14 ], me15 = me[ 15 ]; + + planes[ 0 ].setComponents( me3 - me0, me7 - me4, me11 - me8, me15 - me12 ).normalize(); + planes[ 1 ].setComponents( me3 + me0, me7 + me4, me11 + me8, me15 + me12 ).normalize(); + planes[ 2 ].setComponents( me3 + me1, me7 + me5, me11 + me9, me15 + me13 ).normalize(); + planes[ 3 ].setComponents( me3 - me1, me7 - me5, me11 - me9, me15 - me13 ).normalize(); + planes[ 4 ].setComponents( me3 - me2, me7 - me6, me11 - me10, me15 - me14 ).normalize(); + planes[ 5 ].setComponents( me3 + me2, me7 + me6, me11 + me10, me15 + me14 ).normalize(); + + return this; + + }, + + intersectsObject: function () { + + var sphere = new Sphere(); + + return function intersectsObject( object ) { + + var geometry = object.geometry; + + if ( geometry.boundingSphere === null ) + geometry.computeBoundingSphere(); + + sphere.copy( geometry.boundingSphere ) + .applyMatrix4( object.matrixWorld ); + + return this.intersectsSphere( sphere ); + + }; + + }(), + + intersectsSprite: function () { + + var sphere = new Sphere(); + + return function intersectsSprite( sprite ) { + + sphere.center.set( 0, 0, 0 ); + sphere.radius = 0.7071067811865476; + sphere.applyMatrix4( sprite.matrixWorld ); + + return this.intersectsSphere( sphere ); + + }; + + }(), + + intersectsSphere: function ( sphere ) { + + var planes = this.planes; + var center = sphere.center; + var negRadius = - sphere.radius; + + for ( var i = 0; i < 6; i ++ ) { + + var distance = planes[ i ].distanceToPoint( center ); + + if ( distance < negRadius ) { + + return false; + + } + + } + + return true; + + }, + + intersectsBox: function () { + + var p1 = new Vector3(), + p2 = new Vector3(); + + return function intersectsBox( box ) { + + var planes = this.planes; + + for ( var i = 0; i < 6; i ++ ) { + + var plane = planes[ i ]; + + p1.x = plane.normal.x > 0 ? box.min.x : box.max.x; + p2.x = plane.normal.x > 0 ? box.max.x : box.min.x; + p1.y = plane.normal.y > 0 ? box.min.y : box.max.y; + p2.y = plane.normal.y > 0 ? box.max.y : box.min.y; + p1.z = plane.normal.z > 0 ? box.min.z : box.max.z; + p2.z = plane.normal.z > 0 ? box.max.z : box.min.z; + + var d1 = plane.distanceToPoint( p1 ); + var d2 = plane.distanceToPoint( p2 ); + + // if both outside plane, no intersection + + if ( d1 < 0 && d2 < 0 ) { + + return false; + + } + + } + + return true; + + }; + + }(), + + containsPoint: function ( point ) { + + var planes = this.planes; + + for ( var i = 0; i < 6; i ++ ) { + + if ( planes[ i ].distanceToPoint( point ) < 0 ) { + + return false; + + } + + } + + return true; + + } + + } ); + + var alphamap_fragment = "#ifdef USE_ALPHAMAP\n\tdiffuseColor.a *= texture2D( alphaMap, vUv ).g;\n#endif\n"; + + var alphamap_pars_fragment = "#ifdef USE_ALPHAMAP\n\tuniform sampler2D alphaMap;\n#endif\n"; + + var alphatest_fragment = "#ifdef ALPHATEST\n\tif ( diffuseColor.a < ALPHATEST ) discard;\n#endif\n"; + + var aomap_fragment = "#ifdef USE_AOMAP\n\tfloat ambientOcclusion = ( texture2D( aoMap, vUv2 ).r - 1.0 ) * aoMapIntensity + 1.0;\n\treflectedLight.indirectDiffuse *= ambientOcclusion;\n\t#if defined( USE_ENVMAP ) && defined( PHYSICAL )\n\t\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\t\treflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, ambientOcclusion, material.specularRoughness );\n\t#endif\n#endif\n"; + + var aomap_pars_fragment = "#ifdef USE_AOMAP\n\tuniform sampler2D aoMap;\n\tuniform float aoMapIntensity;\n#endif"; + + var begin_vertex = "\nvec3 transformed = vec3( position );\n"; + + var beginnormal_vertex = "\nvec3 objectNormal = vec3( normal );\n"; + + var bsdfs = "float punctualLightIntensityToIrradianceFactor( const in float lightDistance, const in float cutoffDistance, const in float decayExponent ) {\n\tif( decayExponent > 0.0 ) {\n#if defined ( PHYSICALLY_CORRECT_LIGHTS )\n\t\tfloat distanceFalloff = 1.0 / max( pow( lightDistance, decayExponent ), 0.01 );\n\t\tfloat maxDistanceCutoffFactor = pow2( saturate( 1.0 - pow4( lightDistance / cutoffDistance ) ) );\n\t\treturn distanceFalloff * maxDistanceCutoffFactor;\n#else\n\t\treturn pow( saturate( -lightDistance / cutoffDistance + 1.0 ), decayExponent );\n#endif\n\t}\n\treturn 1.0;\n}\nvec3 BRDF_Diffuse_Lambert( const in vec3 diffuseColor ) {\n\treturn RECIPROCAL_PI * diffuseColor;\n}\nvec3 F_Schlick( const in vec3 specularColor, const in float dotLH ) {\n\tfloat fresnel = exp2( ( -5.55473 * dotLH - 6.98316 ) * dotLH );\n\treturn ( 1.0 - specularColor ) * fresnel + specularColor;\n}\nfloat G_GGX_Smith( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gl = dotNL + sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\tfloat gv = dotNV + sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\treturn 1.0 / ( gl * gv );\n}\nfloat G_GGX_SmithCorrelated( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gv = dotNL * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\tfloat gl = dotNV * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\treturn 0.5 / max( gv + gl, EPSILON );\n}\nfloat D_GGX( const in float alpha, const in float dotNH ) {\n\tfloat a2 = pow2( alpha );\n\tfloat denom = pow2( dotNH ) * ( a2 - 1.0 ) + 1.0;\n\treturn RECIPROCAL_PI * a2 / pow2( denom );\n}\nvec3 BRDF_Specular_GGX( const in IncidentLight incidentLight, const in GeometricContext geometry, const in vec3 specularColor, const in float roughness ) {\n\tfloat alpha = pow2( roughness );\n\tvec3 halfDir = normalize( incidentLight.direction + geometry.viewDir );\n\tfloat dotNL = saturate( dot( geometry.normal, incidentLight.direction ) );\n\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\tfloat dotNH = saturate( dot( geometry.normal, halfDir ) );\n\tfloat dotLH = saturate( dot( incidentLight.direction, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, dotLH );\n\tfloat G = G_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\tfloat D = D_GGX( alpha, dotNH );\n\treturn F * ( G * D );\n}\nvec2 LTC_Uv( const in vec3 N, const in vec3 V, const in float roughness ) {\n\tconst float LUT_SIZE = 64.0;\n\tconst float LUT_SCALE = ( LUT_SIZE - 1.0 ) / LUT_SIZE;\n\tconst float LUT_BIAS = 0.5 / LUT_SIZE;\n\tfloat dotNV = saturate( dot( N, V ) );\n\tvec2 uv = vec2( roughness, sqrt( 1.0 - dotNV ) );\n\tuv = uv * LUT_SCALE + LUT_BIAS;\n\treturn uv;\n}\nfloat LTC_ClippedSphereFormFactor( const in vec3 f ) {\n\tfloat l = length( f );\n\treturn max( ( l * l + f.z ) / ( l + 1.0 ), 0.0 );\n}\nvec3 LTC_EdgeVectorFormFactor( const in vec3 v1, const in vec3 v2 ) {\n\tfloat x = dot( v1, v2 );\n\tfloat y = abs( x );\n\tfloat a = 0.8543985 + ( 0.4965155 + 0.0145206 * y ) * y;\n\tfloat b = 3.4175940 + ( 4.1616724 + y ) * y;\n\tfloat v = a / b;\n\tfloat theta_sintheta = ( x > 0.0 ) ? v : 0.5 * inversesqrt( max( 1.0 - x * x, 1e-7 ) ) - v;\n\treturn cross( v1, v2 ) * theta_sintheta;\n}\nvec3 LTC_Evaluate( const in vec3 N, const in vec3 V, const in vec3 P, const in mat3 mInv, const in vec3 rectCoords[ 4 ] ) {\n\tvec3 v1 = rectCoords[ 1 ] - rectCoords[ 0 ];\n\tvec3 v2 = rectCoords[ 3 ] - rectCoords[ 0 ];\n\tvec3 lightNormal = cross( v1, v2 );\n\tif( dot( lightNormal, P - rectCoords[ 0 ] ) < 0.0 ) return vec3( 0.0 );\n\tvec3 T1, T2;\n\tT1 = normalize( V - N * dot( V, N ) );\n\tT2 = - cross( N, T1 );\n\tmat3 mat = mInv * transposeMat3( mat3( T1, T2, N ) );\n\tvec3 coords[ 4 ];\n\tcoords[ 0 ] = mat * ( rectCoords[ 0 ] - P );\n\tcoords[ 1 ] = mat * ( rectCoords[ 1 ] - P );\n\tcoords[ 2 ] = mat * ( rectCoords[ 2 ] - P );\n\tcoords[ 3 ] = mat * ( rectCoords[ 3 ] - P );\n\tcoords[ 0 ] = normalize( coords[ 0 ] );\n\tcoords[ 1 ] = normalize( coords[ 1 ] );\n\tcoords[ 2 ] = normalize( coords[ 2 ] );\n\tcoords[ 3 ] = normalize( coords[ 3 ] );\n\tvec3 vectorFormFactor = vec3( 0.0 );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 0 ], coords[ 1 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 1 ], coords[ 2 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 2 ], coords[ 3 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 3 ], coords[ 0 ] );\n\tfloat result = LTC_ClippedSphereFormFactor( vectorFormFactor );\n\treturn vec3( result );\n}\nvec3 BRDF_Specular_GGX_Environment( const in GeometricContext geometry, const in vec3 specularColor, const in float roughness ) {\n\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\tconst vec4 c0 = vec4( - 1, - 0.0275, - 0.572, 0.022 );\n\tconst vec4 c1 = vec4( 1, 0.0425, 1.04, - 0.04 );\n\tvec4 r = roughness * c0 + c1;\n\tfloat a004 = min( r.x * r.x, exp2( - 9.28 * dotNV ) ) * r.x + r.y;\n\tvec2 AB = vec2( -1.04, 1.04 ) * a004 + r.zw;\n\treturn specularColor * AB.x + AB.y;\n}\nfloat G_BlinnPhong_Implicit( ) {\n\treturn 0.25;\n}\nfloat D_BlinnPhong( const in float shininess, const in float dotNH ) {\n\treturn RECIPROCAL_PI * ( shininess * 0.5 + 1.0 ) * pow( dotNH, shininess );\n}\nvec3 BRDF_Specular_BlinnPhong( const in IncidentLight incidentLight, const in GeometricContext geometry, const in vec3 specularColor, const in float shininess ) {\n\tvec3 halfDir = normalize( incidentLight.direction + geometry.viewDir );\n\tfloat dotNH = saturate( dot( geometry.normal, halfDir ) );\n\tfloat dotLH = saturate( dot( incidentLight.direction, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, dotLH );\n\tfloat G = G_BlinnPhong_Implicit( );\n\tfloat D = D_BlinnPhong( shininess, dotNH );\n\treturn F * ( G * D );\n}\nfloat GGXRoughnessToBlinnExponent( const in float ggxRoughness ) {\n\treturn ( 2.0 / pow2( ggxRoughness + 0.0001 ) - 2.0 );\n}\nfloat BlinnExponentToGGXRoughness( const in float blinnExponent ) {\n\treturn sqrt( 2.0 / ( blinnExponent + 2.0 ) );\n}\n"; + + var bumpmap_pars_fragment = "#ifdef USE_BUMPMAP\n\tuniform sampler2D bumpMap;\n\tuniform float bumpScale;\n\tvec2 dHdxy_fwd() {\n\t\tvec2 dSTdx = dFdx( vUv );\n\t\tvec2 dSTdy = dFdy( vUv );\n\t\tfloat Hll = bumpScale * texture2D( bumpMap, vUv ).x;\n\t\tfloat dBx = bumpScale * texture2D( bumpMap, vUv + dSTdx ).x - Hll;\n\t\tfloat dBy = bumpScale * texture2D( bumpMap, vUv + dSTdy ).x - Hll;\n\t\treturn vec2( dBx, dBy );\n\t}\n\tvec3 perturbNormalArb( vec3 surf_pos, vec3 surf_norm, vec2 dHdxy ) {\n\t\tvec3 vSigmaX = vec3( dFdx( surf_pos.x ), dFdx( surf_pos.y ), dFdx( surf_pos.z ) );\n\t\tvec3 vSigmaY = vec3( dFdy( surf_pos.x ), dFdy( surf_pos.y ), dFdy( surf_pos.z ) );\n\t\tvec3 vN = surf_norm;\n\t\tvec3 R1 = cross( vSigmaY, vN );\n\t\tvec3 R2 = cross( vN, vSigmaX );\n\t\tfloat fDet = dot( vSigmaX, R1 );\n\t\tfDet *= ( float( gl_FrontFacing ) * 2.0 - 1.0 );\n\t\tvec3 vGrad = sign( fDet ) * ( dHdxy.x * R1 + dHdxy.y * R2 );\n\t\treturn normalize( abs( fDet ) * surf_norm - vGrad );\n\t}\n#endif\n"; + + var clipping_planes_fragment = "#if NUM_CLIPPING_PLANES > 0\n\tvec4 plane;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {\n\t\tplane = clippingPlanes[ i ];\n\t\tif ( dot( vViewPosition, plane.xyz ) > plane.w ) discard;\n\t}\n\t#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES\n\t\tbool clipped = true;\n\t\t#pragma unroll_loop\n\t\tfor ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {\n\t\t\tplane = clippingPlanes[ i ];\n\t\t\tclipped = ( dot( vViewPosition, plane.xyz ) > plane.w ) && clipped;\n\t\t}\n\t\tif ( clipped ) discard;\n\t#endif\n#endif\n"; + + var clipping_planes_pars_fragment = "#if NUM_CLIPPING_PLANES > 0\n\t#if ! defined( PHYSICAL ) && ! defined( PHONG )\n\t\tvarying vec3 vViewPosition;\n\t#endif\n\tuniform vec4 clippingPlanes[ NUM_CLIPPING_PLANES ];\n#endif\n"; + + var clipping_planes_pars_vertex = "#if NUM_CLIPPING_PLANES > 0 && ! defined( PHYSICAL ) && ! defined( PHONG )\n\tvarying vec3 vViewPosition;\n#endif\n"; + + var clipping_planes_vertex = "#if NUM_CLIPPING_PLANES > 0 && ! defined( PHYSICAL ) && ! defined( PHONG )\n\tvViewPosition = - mvPosition.xyz;\n#endif\n"; + + var color_fragment = "#ifdef USE_COLOR\n\tdiffuseColor.rgb *= vColor;\n#endif"; + + var color_pars_fragment = "#ifdef USE_COLOR\n\tvarying vec3 vColor;\n#endif\n"; + + var color_pars_vertex = "#ifdef USE_COLOR\n\tvarying vec3 vColor;\n#endif"; + + var color_vertex = "#ifdef USE_COLOR\n\tvColor.xyz = color.xyz;\n#endif"; + + var common = "#define PI 3.14159265359\n#define PI2 6.28318530718\n#define PI_HALF 1.5707963267949\n#define RECIPROCAL_PI 0.31830988618\n#define RECIPROCAL_PI2 0.15915494\n#define LOG2 1.442695\n#define EPSILON 1e-6\n#define saturate(a) clamp( a, 0.0, 1.0 )\n#define whiteCompliment(a) ( 1.0 - saturate( a ) )\nfloat pow2( const in float x ) { return x*x; }\nfloat pow3( const in float x ) { return x*x*x; }\nfloat pow4( const in float x ) { float x2 = x*x; return x2*x2; }\nfloat average( const in vec3 color ) { return dot( color, vec3( 0.3333 ) ); }\nhighp float rand( const in vec2 uv ) {\n\tconst highp float a = 12.9898, b = 78.233, c = 43758.5453;\n\thighp float dt = dot( uv.xy, vec2( a,b ) ), sn = mod( dt, PI );\n\treturn fract(sin(sn) * c);\n}\nstruct IncidentLight {\n\tvec3 color;\n\tvec3 direction;\n\tbool visible;\n};\nstruct ReflectedLight {\n\tvec3 directDiffuse;\n\tvec3 directSpecular;\n\tvec3 indirectDiffuse;\n\tvec3 indirectSpecular;\n};\nstruct GeometricContext {\n\tvec3 position;\n\tvec3 normal;\n\tvec3 viewDir;\n};\nvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n}\nvec3 inverseTransformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( vec4( dir, 0.0 ) * matrix ).xyz );\n}\nvec3 projectOnPlane(in vec3 point, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\tfloat distance = dot( planeNormal, point - pointOnPlane );\n\treturn - distance * planeNormal + point;\n}\nfloat sideOfPlane( in vec3 point, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\treturn sign( dot( point - pointOnPlane, planeNormal ) );\n}\nvec3 linePlaneIntersect( in vec3 pointOnLine, in vec3 lineDirection, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\treturn lineDirection * ( dot( planeNormal, pointOnPlane - pointOnLine ) / dot( planeNormal, lineDirection ) ) + pointOnLine;\n}\nmat3 transposeMat3( const in mat3 m ) {\n\tmat3 tmp;\n\ttmp[ 0 ] = vec3( m[ 0 ].x, m[ 1 ].x, m[ 2 ].x );\n\ttmp[ 1 ] = vec3( m[ 0 ].y, m[ 1 ].y, m[ 2 ].y );\n\ttmp[ 2 ] = vec3( m[ 0 ].z, m[ 1 ].z, m[ 2 ].z );\n\treturn tmp;\n}\nfloat linearToRelativeLuminance( const in vec3 color ) {\n\tvec3 weights = vec3( 0.2126, 0.7152, 0.0722 );\n\treturn dot( weights, color.rgb );\n}\n"; + + var cube_uv_reflection_fragment = "#ifdef ENVMAP_TYPE_CUBE_UV\n#define cubeUV_textureSize (1024.0)\nint getFaceFromDirection(vec3 direction) {\n\tvec3 absDirection = abs(direction);\n\tint face = -1;\n\tif( absDirection.x > absDirection.z ) {\n\t\tif(absDirection.x > absDirection.y )\n\t\t\tface = direction.x > 0.0 ? 0 : 3;\n\t\telse\n\t\t\tface = direction.y > 0.0 ? 1 : 4;\n\t}\n\telse {\n\t\tif(absDirection.z > absDirection.y )\n\t\t\tface = direction.z > 0.0 ? 2 : 5;\n\t\telse\n\t\t\tface = direction.y > 0.0 ? 1 : 4;\n\t}\n\treturn face;\n}\n#define cubeUV_maxLods1 (log2(cubeUV_textureSize*0.25) - 1.0)\n#define cubeUV_rangeClamp (exp2((6.0 - 1.0) * 2.0))\nvec2 MipLevelInfo( vec3 vec, float roughnessLevel, float roughness ) {\n\tfloat scale = exp2(cubeUV_maxLods1 - roughnessLevel);\n\tfloat dxRoughness = dFdx(roughness);\n\tfloat dyRoughness = dFdy(roughness);\n\tvec3 dx = dFdx( vec * scale * dxRoughness );\n\tvec3 dy = dFdy( vec * scale * dyRoughness );\n\tfloat d = max( dot( dx, dx ), dot( dy, dy ) );\n\td = clamp(d, 1.0, cubeUV_rangeClamp);\n\tfloat mipLevel = 0.5 * log2(d);\n\treturn vec2(floor(mipLevel), fract(mipLevel));\n}\n#define cubeUV_maxLods2 (log2(cubeUV_textureSize*0.25) - 2.0)\n#define cubeUV_rcpTextureSize (1.0 / cubeUV_textureSize)\nvec2 getCubeUV(vec3 direction, float roughnessLevel, float mipLevel) {\n\tmipLevel = roughnessLevel > cubeUV_maxLods2 - 3.0 ? 0.0 : mipLevel;\n\tfloat a = 16.0 * cubeUV_rcpTextureSize;\n\tvec2 exp2_packed = exp2( vec2( roughnessLevel, mipLevel ) );\n\tvec2 rcp_exp2_packed = vec2( 1.0 ) / exp2_packed;\n\tfloat powScale = exp2_packed.x * exp2_packed.y;\n\tfloat scale = rcp_exp2_packed.x * rcp_exp2_packed.y * 0.25;\n\tfloat mipOffset = 0.75*(1.0 - rcp_exp2_packed.y) * rcp_exp2_packed.x;\n\tbool bRes = mipLevel == 0.0;\n\tscale = bRes && (scale < a) ? a : scale;\n\tvec3 r;\n\tvec2 offset;\n\tint face = getFaceFromDirection(direction);\n\tfloat rcpPowScale = 1.0 / powScale;\n\tif( face == 0) {\n\t\tr = vec3(direction.x, -direction.z, direction.y);\n\t\toffset = vec2(0.0+mipOffset,0.75 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? a : offset.y;\n\t}\n\telse if( face == 1) {\n\t\tr = vec3(direction.y, direction.x, direction.z);\n\t\toffset = vec2(scale+mipOffset, 0.75 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? a : offset.y;\n\t}\n\telse if( face == 2) {\n\t\tr = vec3(direction.z, direction.x, direction.y);\n\t\toffset = vec2(2.0*scale+mipOffset, 0.75 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? a : offset.y;\n\t}\n\telse if( face == 3) {\n\t\tr = vec3(direction.x, direction.z, direction.y);\n\t\toffset = vec2(0.0+mipOffset,0.5 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? 0.0 : offset.y;\n\t}\n\telse if( face == 4) {\n\t\tr = vec3(direction.y, direction.x, -direction.z);\n\t\toffset = vec2(scale+mipOffset, 0.5 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? 0.0 : offset.y;\n\t}\n\telse {\n\t\tr = vec3(direction.z, -direction.x, direction.y);\n\t\toffset = vec2(2.0*scale+mipOffset, 0.5 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? 0.0 : offset.y;\n\t}\n\tr = normalize(r);\n\tfloat texelOffset = 0.5 * cubeUV_rcpTextureSize;\n\tvec2 s = ( r.yz / abs( r.x ) + vec2( 1.0 ) ) * 0.5;\n\tvec2 base = offset + vec2( texelOffset );\n\treturn base + s * ( scale - 2.0 * texelOffset );\n}\n#define cubeUV_maxLods3 (log2(cubeUV_textureSize*0.25) - 3.0)\nvec4 textureCubeUV(vec3 reflectedDirection, float roughness ) {\n\tfloat roughnessVal = roughness* cubeUV_maxLods3;\n\tfloat r1 = floor(roughnessVal);\n\tfloat r2 = r1 + 1.0;\n\tfloat t = fract(roughnessVal);\n\tvec2 mipInfo = MipLevelInfo(reflectedDirection, r1, roughness);\n\tfloat s = mipInfo.y;\n\tfloat level0 = mipInfo.x;\n\tfloat level1 = level0 + 1.0;\n\tlevel1 = level1 > 5.0 ? 5.0 : level1;\n\tlevel0 += min( floor( s + 0.5 ), 5.0 );\n\tvec2 uv_10 = getCubeUV(reflectedDirection, r1, level0);\n\tvec4 color10 = envMapTexelToLinear(texture2D(envMap, uv_10));\n\tvec2 uv_20 = getCubeUV(reflectedDirection, r2, level0);\n\tvec4 color20 = envMapTexelToLinear(texture2D(envMap, uv_20));\n\tvec4 result = mix(color10, color20, t);\n\treturn vec4(result.rgb, 1.0);\n}\n#endif\n"; + + var defaultnormal_vertex = "vec3 transformedNormal = normalMatrix * objectNormal;\n#ifdef FLIP_SIDED\n\ttransformedNormal = - transformedNormal;\n#endif\n"; + + var displacementmap_pars_vertex = "#ifdef USE_DISPLACEMENTMAP\n\tuniform sampler2D displacementMap;\n\tuniform float displacementScale;\n\tuniform float displacementBias;\n#endif\n"; + + var displacementmap_vertex = "#ifdef USE_DISPLACEMENTMAP\n\ttransformed += normalize( objectNormal ) * ( texture2D( displacementMap, uv ).x * displacementScale + displacementBias );\n#endif\n"; + + var emissivemap_fragment = "#ifdef USE_EMISSIVEMAP\n\tvec4 emissiveColor = texture2D( emissiveMap, vUv );\n\temissiveColor.rgb = emissiveMapTexelToLinear( emissiveColor ).rgb;\n\ttotalEmissiveRadiance *= emissiveColor.rgb;\n#endif\n"; + + var emissivemap_pars_fragment = "#ifdef USE_EMISSIVEMAP\n\tuniform sampler2D emissiveMap;\n#endif\n"; + + var encodings_fragment = " gl_FragColor = linearToOutputTexel( gl_FragColor );\n"; + + var encodings_pars_fragment = "\nvec4 LinearToLinear( in vec4 value ) {\n\treturn value;\n}\nvec4 GammaToLinear( in vec4 value, in float gammaFactor ) {\n\treturn vec4( pow( value.xyz, vec3( gammaFactor ) ), value.w );\n}\nvec4 LinearToGamma( in vec4 value, in float gammaFactor ) {\n\treturn vec4( pow( value.xyz, vec3( 1.0 / gammaFactor ) ), value.w );\n}\nvec4 sRGBToLinear( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), value.rgb * 0.0773993808, vec3( lessThanEqual( value.rgb, vec3( 0.04045 ) ) ) ), value.w );\n}\nvec4 LinearTosRGB( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.w );\n}\nvec4 RGBEToLinear( in vec4 value ) {\n\treturn vec4( value.rgb * exp2( value.a * 255.0 - 128.0 ), 1.0 );\n}\nvec4 LinearToRGBE( in vec4 value ) {\n\tfloat maxComponent = max( max( value.r, value.g ), value.b );\n\tfloat fExp = clamp( ceil( log2( maxComponent ) ), -128.0, 127.0 );\n\treturn vec4( value.rgb / exp2( fExp ), ( fExp + 128.0 ) / 255.0 );\n}\nvec4 RGBMToLinear( in vec4 value, in float maxRange ) {\n\treturn vec4( value.xyz * value.w * maxRange, 1.0 );\n}\nvec4 LinearToRGBM( in vec4 value, in float maxRange ) {\n\tfloat maxRGB = max( value.x, max( value.g, value.b ) );\n\tfloat M = clamp( maxRGB / maxRange, 0.0, 1.0 );\n\tM = ceil( M * 255.0 ) / 255.0;\n\treturn vec4( value.rgb / ( M * maxRange ), M );\n}\nvec4 RGBDToLinear( in vec4 value, in float maxRange ) {\n\treturn vec4( value.rgb * ( ( maxRange / 255.0 ) / value.a ), 1.0 );\n}\nvec4 LinearToRGBD( in vec4 value, in float maxRange ) {\n\tfloat maxRGB = max( value.x, max( value.g, value.b ) );\n\tfloat D = max( maxRange / maxRGB, 1.0 );\n\tD = min( floor( D ) / 255.0, 1.0 );\n\treturn vec4( value.rgb * ( D * ( 255.0 / maxRange ) ), D );\n}\nconst mat3 cLogLuvM = mat3( 0.2209, 0.3390, 0.4184, 0.1138, 0.6780, 0.7319, 0.0102, 0.1130, 0.2969 );\nvec4 LinearToLogLuv( in vec4 value ) {\n\tvec3 Xp_Y_XYZp = value.rgb * cLogLuvM;\n\tXp_Y_XYZp = max(Xp_Y_XYZp, vec3(1e-6, 1e-6, 1e-6));\n\tvec4 vResult;\n\tvResult.xy = Xp_Y_XYZp.xy / Xp_Y_XYZp.z;\n\tfloat Le = 2.0 * log2(Xp_Y_XYZp.y) + 127.0;\n\tvResult.w = fract(Le);\n\tvResult.z = (Le - (floor(vResult.w*255.0))/255.0)/255.0;\n\treturn vResult;\n}\nconst mat3 cLogLuvInverseM = mat3( 6.0014, -2.7008, -1.7996, -1.3320, 3.1029, -5.7721, 0.3008, -1.0882, 5.6268 );\nvec4 LogLuvToLinear( in vec4 value ) {\n\tfloat Le = value.z * 255.0 + value.w;\n\tvec3 Xp_Y_XYZp;\n\tXp_Y_XYZp.y = exp2((Le - 127.0) / 2.0);\n\tXp_Y_XYZp.z = Xp_Y_XYZp.y / value.y;\n\tXp_Y_XYZp.x = value.x * Xp_Y_XYZp.z;\n\tvec3 vRGB = Xp_Y_XYZp.rgb * cLogLuvInverseM;\n\treturn vec4( max(vRGB, 0.0), 1.0 );\n}\n"; + + var envmap_fragment = "#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG )\n\t\tvec3 cameraToVertex = normalize( vWorldPosition - cameraPosition );\n\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( cameraToVertex, worldNormal );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( cameraToVertex, worldNormal, refractionRatio );\n\t\t#endif\n\t#else\n\t\tvec3 reflectVec = vReflect;\n\t#endif\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 envColor = textureCube( envMap, vec3( flipEnvMap * reflectVec.x, reflectVec.yz ) );\n\t#elif defined( ENVMAP_TYPE_EQUIREC )\n\t\tvec2 sampleUV;\n\t\treflectVec = normalize( reflectVec );\n\t\tsampleUV.y = asin( clamp( reflectVec.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\t\tsampleUV.x = atan( reflectVec.z, reflectVec.x ) * RECIPROCAL_PI2 + 0.5;\n\t\tvec4 envColor = texture2D( envMap, sampleUV );\n\t#elif defined( ENVMAP_TYPE_SPHERE )\n\t\treflectVec = normalize( reflectVec );\n\t\tvec3 reflectView = normalize( ( viewMatrix * vec4( reflectVec, 0.0 ) ).xyz + vec3( 0.0, 0.0, 1.0 ) );\n\t\tvec4 envColor = texture2D( envMap, reflectView.xy * 0.5 + 0.5 );\n\t#else\n\t\tvec4 envColor = vec4( 0.0 );\n\t#endif\n\tenvColor = envMapTexelToLinear( envColor );\n\t#ifdef ENVMAP_BLENDING_MULTIPLY\n\t\toutgoingLight = mix( outgoingLight, outgoingLight * envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_MIX )\n\t\toutgoingLight = mix( outgoingLight, envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_ADD )\n\t\toutgoingLight += envColor.xyz * specularStrength * reflectivity;\n\t#endif\n#endif\n"; + + var envmap_pars_fragment = "#if defined( USE_ENVMAP ) || defined( PHYSICAL )\n\tuniform float reflectivity;\n\tuniform float envMapIntensity;\n#endif\n#ifdef USE_ENVMAP\n\t#if ! defined( PHYSICAL ) && ( defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) )\n\t\tvarying vec3 vWorldPosition;\n\t#endif\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tuniform samplerCube envMap;\n\t#else\n\t\tuniform sampler2D envMap;\n\t#endif\n\tuniform float flipEnvMap;\n\tuniform int maxMipLevel;\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) || defined( PHYSICAL )\n\t\tuniform float refractionRatio;\n\t#else\n\t\tvarying vec3 vReflect;\n\t#endif\n#endif\n"; + + var envmap_pars_vertex = "#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG )\n\t\tvarying vec3 vWorldPosition;\n\t#else\n\t\tvarying vec3 vReflect;\n\t\tuniform float refractionRatio;\n\t#endif\n#endif\n"; + + var envmap_vertex = "#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG )\n\t\tvWorldPosition = worldPosition.xyz;\n\t#else\n\t\tvec3 cameraToVertex = normalize( worldPosition.xyz - cameraPosition );\n\t\tvec3 worldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvReflect = reflect( cameraToVertex, worldNormal );\n\t\t#else\n\t\t\tvReflect = refract( cameraToVertex, worldNormal, refractionRatio );\n\t\t#endif\n\t#endif\n#endif\n"; + + var fog_vertex = "\n#ifdef USE_FOG\nfogDepth = -mvPosition.z;\n#endif"; + + var fog_pars_vertex = "#ifdef USE_FOG\n varying float fogDepth;\n#endif\n"; + + var fog_fragment = "#ifdef USE_FOG\n\t#ifdef FOG_EXP2\n\t\tfloat fogFactor = whiteCompliment( exp2( - fogDensity * fogDensity * fogDepth * fogDepth * LOG2 ) );\n\t#else\n\t\tfloat fogFactor = smoothstep( fogNear, fogFar, fogDepth );\n\t#endif\n\tgl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );\n#endif\n"; + + var fog_pars_fragment = "#ifdef USE_FOG\n\tuniform vec3 fogColor;\n\tvarying float fogDepth;\n\t#ifdef FOG_EXP2\n\t\tuniform float fogDensity;\n\t#else\n\t\tuniform float fogNear;\n\t\tuniform float fogFar;\n\t#endif\n#endif\n"; + + var gradientmap_pars_fragment = "#ifdef TOON\n\tuniform sampler2D gradientMap;\n\tvec3 getGradientIrradiance( vec3 normal, vec3 lightDirection ) {\n\t\tfloat dotNL = dot( normal, lightDirection );\n\t\tvec2 coord = vec2( dotNL * 0.5 + 0.5, 0.0 );\n\t\t#ifdef USE_GRADIENTMAP\n\t\t\treturn texture2D( gradientMap, coord ).rgb;\n\t\t#else\n\t\t\treturn ( coord.x < 0.7 ) ? vec3( 0.7 ) : vec3( 1.0 );\n\t\t#endif\n\t}\n#endif\n"; + + var lightmap_fragment = "#ifdef USE_LIGHTMAP\n\treflectedLight.indirectDiffuse += PI * texture2D( lightMap, vUv2 ).xyz * lightMapIntensity;\n#endif\n"; + + var lightmap_pars_fragment = "#ifdef USE_LIGHTMAP\n\tuniform sampler2D lightMap;\n\tuniform float lightMapIntensity;\n#endif"; + + var lights_lambert_vertex = "vec3 diffuse = vec3( 1.0 );\nGeometricContext geometry;\ngeometry.position = mvPosition.xyz;\ngeometry.normal = normalize( transformedNormal );\ngeometry.viewDir = normalize( -mvPosition.xyz );\nGeometricContext backGeometry;\nbackGeometry.position = geometry.position;\nbackGeometry.normal = -geometry.normal;\nbackGeometry.viewDir = geometry.viewDir;\nvLightFront = vec3( 0.0 );\n#ifdef DOUBLE_SIDED\n\tvLightBack = vec3( 0.0 );\n#endif\nIncidentLight directLight;\nfloat dotNL;\nvec3 directLightColor_Diffuse;\n#if NUM_POINT_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tgetPointDirectLightIrradiance( pointLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tgetSpotDirectLightIrradiance( spotLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n#endif\n#if NUM_DIR_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tgetDirectionalDirectLightIrradiance( directionalLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\tvLightFront += getHemisphereLightIrradiance( hemisphereLights[ i ], geometry );\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += getHemisphereLightIrradiance( hemisphereLights[ i ], backGeometry );\n\t\t#endif\n\t}\n#endif\n"; + + var lights_pars_begin = "uniform vec3 ambientLightColor;\nvec3 getAmbientLightIrradiance( const in vec3 ambientLightColor ) {\n\tvec3 irradiance = ambientLightColor;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treturn irradiance;\n}\n#if NUM_DIR_LIGHTS > 0\n\tstruct DirectionalLight {\n\t\tvec3 direction;\n\t\tvec3 color;\n\t\tint shadow;\n\t\tfloat shadowBias;\n\t\tfloat shadowRadius;\n\t\tvec2 shadowMapSize;\n\t};\n\tuniform DirectionalLight directionalLights[ NUM_DIR_LIGHTS ];\n\tvoid getDirectionalDirectLightIrradiance( const in DirectionalLight directionalLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tdirectLight.color = directionalLight.color;\n\t\tdirectLight.direction = directionalLight.direction;\n\t\tdirectLight.visible = true;\n\t}\n#endif\n#if NUM_POINT_LIGHTS > 0\n\tstruct PointLight {\n\t\tvec3 position;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t\tint shadow;\n\t\tfloat shadowBias;\n\t\tfloat shadowRadius;\n\t\tvec2 shadowMapSize;\n\t\tfloat shadowCameraNear;\n\t\tfloat shadowCameraFar;\n\t};\n\tuniform PointLight pointLights[ NUM_POINT_LIGHTS ];\n\tvoid getPointDirectLightIrradiance( const in PointLight pointLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tvec3 lVector = pointLight.position - geometry.position;\n\t\tdirectLight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tdirectLight.color = pointLight.color;\n\t\tdirectLight.color *= punctualLightIntensityToIrradianceFactor( lightDistance, pointLight.distance, pointLight.decay );\n\t\tdirectLight.visible = ( directLight.color != vec3( 0.0 ) );\n\t}\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\tstruct SpotLight {\n\t\tvec3 position;\n\t\tvec3 direction;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t\tfloat coneCos;\n\t\tfloat penumbraCos;\n\t\tint shadow;\n\t\tfloat shadowBias;\n\t\tfloat shadowRadius;\n\t\tvec2 shadowMapSize;\n\t};\n\tuniform SpotLight spotLights[ NUM_SPOT_LIGHTS ];\n\tvoid getSpotDirectLightIrradiance( const in SpotLight spotLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tvec3 lVector = spotLight.position - geometry.position;\n\t\tdirectLight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tfloat angleCos = dot( directLight.direction, spotLight.direction );\n\t\tif ( angleCos > spotLight.coneCos ) {\n\t\t\tfloat spotEffect = smoothstep( spotLight.coneCos, spotLight.penumbraCos, angleCos );\n\t\t\tdirectLight.color = spotLight.color;\n\t\t\tdirectLight.color *= spotEffect * punctualLightIntensityToIrradianceFactor( lightDistance, spotLight.distance, spotLight.decay );\n\t\t\tdirectLight.visible = true;\n\t\t} else {\n\t\t\tdirectLight.color = vec3( 0.0 );\n\t\t\tdirectLight.visible = false;\n\t\t}\n\t}\n#endif\n#if NUM_RECT_AREA_LIGHTS > 0\n\tstruct RectAreaLight {\n\t\tvec3 color;\n\t\tvec3 position;\n\t\tvec3 halfWidth;\n\t\tvec3 halfHeight;\n\t};\n\tuniform sampler2D ltc_1;\tuniform sampler2D ltc_2;\n\tuniform RectAreaLight rectAreaLights[ NUM_RECT_AREA_LIGHTS ];\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\tstruct HemisphereLight {\n\t\tvec3 direction;\n\t\tvec3 skyColor;\n\t\tvec3 groundColor;\n\t};\n\tuniform HemisphereLight hemisphereLights[ NUM_HEMI_LIGHTS ];\n\tvec3 getHemisphereLightIrradiance( const in HemisphereLight hemiLight, const in GeometricContext geometry ) {\n\t\tfloat dotNL = dot( geometry.normal, hemiLight.direction );\n\t\tfloat hemiDiffuseWeight = 0.5 * dotNL + 0.5;\n\t\tvec3 irradiance = mix( hemiLight.groundColor, hemiLight.skyColor, hemiDiffuseWeight );\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tirradiance *= PI;\n\t\t#endif\n\t\treturn irradiance;\n\t}\n#endif\n"; + + var lights_pars_maps = "#if defined( USE_ENVMAP ) && defined( PHYSICAL )\n\tvec3 getLightProbeIndirectIrradiance( const in GeometricContext geometry, const in int maxMIPLevel ) {\n\t\tvec3 worldNormal = inverseTransformDirection( geometry.normal, viewMatrix );\n\t\t#ifdef ENVMAP_TYPE_CUBE\n\t\t\tvec3 queryVec = vec3( flipEnvMap * worldNormal.x, worldNormal.yz );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = textureCubeLodEXT( envMap, queryVec, float( maxMIPLevel ) );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = textureCube( envMap, queryVec, float( maxMIPLevel ) );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\t\tvec3 queryVec = vec3( flipEnvMap * worldNormal.x, worldNormal.yz );\n\t\t\tvec4 envMapColor = textureCubeUV( queryVec, 1.0 );\n\t\t#else\n\t\t\tvec4 envMapColor = vec4( 0.0 );\n\t\t#endif\n\t\treturn PI * envMapColor.rgb * envMapIntensity;\n\t}\n\tfloat getSpecularMIPLevel( const in float blinnShininessExponent, const in int maxMIPLevel ) {\n\t\tfloat maxMIPLevelScalar = float( maxMIPLevel );\n\t\tfloat desiredMIPLevel = maxMIPLevelScalar + 0.79248 - 0.5 * log2( pow2( blinnShininessExponent ) + 1.0 );\n\t\treturn clamp( desiredMIPLevel, 0.0, maxMIPLevelScalar );\n\t}\n\tvec3 getLightProbeIndirectRadiance( const in GeometricContext geometry, const in float blinnShininessExponent, const in int maxMIPLevel ) {\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( -geometry.viewDir, geometry.normal );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( -geometry.viewDir, geometry.normal, refractionRatio );\n\t\t#endif\n\t\treflectVec = inverseTransformDirection( reflectVec, viewMatrix );\n\t\tfloat specularMIPLevel = getSpecularMIPLevel( blinnShininessExponent, maxMIPLevel );\n\t\t#ifdef ENVMAP_TYPE_CUBE\n\t\t\tvec3 queryReflectVec = vec3( flipEnvMap * reflectVec.x, reflectVec.yz );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = textureCubeLodEXT( envMap, queryReflectVec, specularMIPLevel );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = textureCube( envMap, queryReflectVec, specularMIPLevel );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\t\tvec3 queryReflectVec = vec3( flipEnvMap * reflectVec.x, reflectVec.yz );\n\t\t\tvec4 envMapColor = textureCubeUV(queryReflectVec, BlinnExponentToGGXRoughness(blinnShininessExponent));\n\t\t#elif defined( ENVMAP_TYPE_EQUIREC )\n\t\t\tvec2 sampleUV;\n\t\t\tsampleUV.y = asin( clamp( reflectVec.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\t\t\tsampleUV.x = atan( reflectVec.z, reflectVec.x ) * RECIPROCAL_PI2 + 0.5;\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = texture2DLodEXT( envMap, sampleUV, specularMIPLevel );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = texture2D( envMap, sampleUV, specularMIPLevel );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_SPHERE )\n\t\t\tvec3 reflectView = normalize( ( viewMatrix * vec4( reflectVec, 0.0 ) ).xyz + vec3( 0.0,0.0,1.0 ) );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = texture2DLodEXT( envMap, reflectView.xy * 0.5 + 0.5, specularMIPLevel );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = texture2D( envMap, reflectView.xy * 0.5 + 0.5, specularMIPLevel );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#endif\n\t\treturn envMapColor.rgb * envMapIntensity;\n\t}\n#endif\n"; + + var lights_phong_fragment = "BlinnPhongMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularColor = specular;\nmaterial.specularShininess = shininess;\nmaterial.specularStrength = specularStrength;\n"; + + var lights_phong_pars_fragment = "varying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\nstruct BlinnPhongMaterial {\n\tvec3\tdiffuseColor;\n\tvec3\tspecularColor;\n\tfloat\tspecularShininess;\n\tfloat\tspecularStrength;\n};\nvoid RE_Direct_BlinnPhong( const in IncidentLight directLight, const in GeometricContext geometry, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\t#ifdef TOON\n\t\tvec3 irradiance = getGradientIrradiance( geometry.normal, directLight.direction ) * directLight.color;\n\t#else\n\t\tfloat dotNL = saturate( dot( geometry.normal, directLight.direction ) );\n\t\tvec3 irradiance = dotNL * directLight.color;\n\t#endif\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treflectedLight.directDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n\treflectedLight.directSpecular += irradiance * BRDF_Specular_BlinnPhong( directLight, geometry, material.specularColor, material.specularShininess ) * material.specularStrength;\n}\nvoid RE_IndirectDiffuse_BlinnPhong( const in vec3 irradiance, const in GeometricContext geometry, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_BlinnPhong\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_BlinnPhong\n#define Material_LightProbeLOD( material )\t(0)\n"; + + var lights_physical_fragment = "PhysicalMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb * ( 1.0 - metalnessFactor );\nmaterial.specularRoughness = clamp( roughnessFactor, 0.04, 1.0 );\n#ifdef STANDARD\n\tmaterial.specularColor = mix( vec3( DEFAULT_SPECULAR_COEFFICIENT ), diffuseColor.rgb, metalnessFactor );\n#else\n\tmaterial.specularColor = mix( vec3( MAXIMUM_SPECULAR_COEFFICIENT * pow2( reflectivity ) ), diffuseColor.rgb, metalnessFactor );\n\tmaterial.clearCoat = saturate( clearCoat );\tmaterial.clearCoatRoughness = clamp( clearCoatRoughness, 0.04, 1.0 );\n#endif\n"; + + var lights_physical_pars_fragment = "struct PhysicalMaterial {\n\tvec3\tdiffuseColor;\n\tfloat\tspecularRoughness;\n\tvec3\tspecularColor;\n\t#ifndef STANDARD\n\t\tfloat clearCoat;\n\t\tfloat clearCoatRoughness;\n\t#endif\n};\n#define MAXIMUM_SPECULAR_COEFFICIENT 0.16\n#define DEFAULT_SPECULAR_COEFFICIENT 0.04\nfloat clearCoatDHRApprox( const in float roughness, const in float dotNL ) {\n\treturn DEFAULT_SPECULAR_COEFFICIENT + ( 1.0 - DEFAULT_SPECULAR_COEFFICIENT ) * ( pow( 1.0 - dotNL, 5.0 ) * pow( 1.0 - roughness, 2.0 ) );\n}\n#if NUM_RECT_AREA_LIGHTS > 0\n\tvoid RE_Direct_RectArea_Physical( const in RectAreaLight rectAreaLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\t\tvec3 normal = geometry.normal;\n\t\tvec3 viewDir = geometry.viewDir;\n\t\tvec3 position = geometry.position;\n\t\tvec3 lightPos = rectAreaLight.position;\n\t\tvec3 halfWidth = rectAreaLight.halfWidth;\n\t\tvec3 halfHeight = rectAreaLight.halfHeight;\n\t\tvec3 lightColor = rectAreaLight.color;\n\t\tfloat roughness = material.specularRoughness;\n\t\tvec3 rectCoords[ 4 ];\n\t\trectCoords[ 0 ] = lightPos - halfWidth - halfHeight;\t\trectCoords[ 1 ] = lightPos + halfWidth - halfHeight;\n\t\trectCoords[ 2 ] = lightPos + halfWidth + halfHeight;\n\t\trectCoords[ 3 ] = lightPos - halfWidth + halfHeight;\n\t\tvec2 uv = LTC_Uv( normal, viewDir, roughness );\n\t\tvec4 t1 = texture2D( ltc_1, uv );\n\t\tvec4 t2 = texture2D( ltc_2, uv );\n\t\tmat3 mInv = mat3(\n\t\t\tvec3( t1.x, 0, t1.y ),\n\t\t\tvec3( 0, 1, 0 ),\n\t\t\tvec3( t1.z, 0, t1.w )\n\t\t);\n\t\tvec3 fresnel = ( material.specularColor * t2.x + ( vec3( 1.0 ) - material.specularColor ) * t2.y );\n\t\treflectedLight.directSpecular += lightColor * fresnel * LTC_Evaluate( normal, viewDir, position, mInv, rectCoords );\n\t\treflectedLight.directDiffuse += lightColor * material.diffuseColor * LTC_Evaluate( normal, viewDir, position, mat3( 1.0 ), rectCoords );\n\t}\n#endif\nvoid RE_Direct_Physical( const in IncidentLight directLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometry.normal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\t#ifndef STANDARD\n\t\tfloat clearCoatDHR = material.clearCoat * clearCoatDHRApprox( material.clearCoatRoughness, dotNL );\n\t#else\n\t\tfloat clearCoatDHR = 0.0;\n\t#endif\n\treflectedLight.directSpecular += ( 1.0 - clearCoatDHR ) * irradiance * BRDF_Specular_GGX( directLight, geometry, material.specularColor, material.specularRoughness );\n\treflectedLight.directDiffuse += ( 1.0 - clearCoatDHR ) * irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n\t#ifndef STANDARD\n\t\treflectedLight.directSpecular += irradiance * material.clearCoat * BRDF_Specular_GGX( directLight, geometry, vec3( DEFAULT_SPECULAR_COEFFICIENT ), material.clearCoatRoughness );\n\t#endif\n}\nvoid RE_IndirectDiffuse_Physical( const in vec3 irradiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectSpecular_Physical( const in vec3 radiance, const in vec3 clearCoatRadiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\t#ifndef STANDARD\n\t\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\t\tfloat dotNL = dotNV;\n\t\tfloat clearCoatDHR = material.clearCoat * clearCoatDHRApprox( material.clearCoatRoughness, dotNL );\n\t#else\n\t\tfloat clearCoatDHR = 0.0;\n\t#endif\n\treflectedLight.indirectSpecular += ( 1.0 - clearCoatDHR ) * radiance * BRDF_Specular_GGX_Environment( geometry, material.specularColor, material.specularRoughness );\n\t#ifndef STANDARD\n\t\treflectedLight.indirectSpecular += clearCoatRadiance * material.clearCoat * BRDF_Specular_GGX_Environment( geometry, vec3( DEFAULT_SPECULAR_COEFFICIENT ), material.clearCoatRoughness );\n\t#endif\n}\n#define RE_Direct\t\t\t\tRE_Direct_Physical\n#define RE_Direct_RectArea\t\tRE_Direct_RectArea_Physical\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Physical\n#define RE_IndirectSpecular\t\tRE_IndirectSpecular_Physical\n#define Material_BlinnShininessExponent( material ) GGXRoughnessToBlinnExponent( material.specularRoughness )\n#define Material_ClearCoat_BlinnShininessExponent( material ) GGXRoughnessToBlinnExponent( material.clearCoatRoughness )\nfloat computeSpecularOcclusion( const in float dotNV, const in float ambientOcclusion, const in float roughness ) {\n\treturn saturate( pow( dotNV + ambientOcclusion, exp2( - 16.0 * roughness - 1.0 ) ) - 1.0 + ambientOcclusion );\n}\n"; + + var lights_fragment_begin = "\nGeometricContext geometry;\ngeometry.position = - vViewPosition;\ngeometry.normal = normal;\ngeometry.viewDir = normalize( vViewPosition );\nIncidentLight directLight;\n#if ( NUM_POINT_LIGHTS > 0 ) && defined( RE_Direct )\n\tPointLight pointLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tpointLight = pointLights[ i ];\n\t\tgetPointDirectLightIrradiance( pointLight, geometry, directLight );\n\t\t#ifdef USE_SHADOWMAP\n\t\tdirectLight.color *= all( bvec2( pointLight.shadow, directLight.visible ) ) ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n#endif\n#if ( NUM_SPOT_LIGHTS > 0 ) && defined( RE_Direct )\n\tSpotLight spotLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tspotLight = spotLights[ i ];\n\t\tgetSpotDirectLightIrradiance( spotLight, geometry, directLight );\n\t\t#ifdef USE_SHADOWMAP\n\t\tdirectLight.color *= all( bvec2( spotLight.shadow, directLight.visible ) ) ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowBias, spotLight.shadowRadius, vSpotShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n#endif\n#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct )\n\tDirectionalLight directionalLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tdirectionalLight = directionalLights[ i ];\n\t\tgetDirectionalDirectLightIrradiance( directionalLight, geometry, directLight );\n\t\t#ifdef USE_SHADOWMAP\n\t\tdirectLight.color *= all( bvec2( directionalLight.shadow, directLight.visible ) ) ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n#endif\n#if ( NUM_RECT_AREA_LIGHTS > 0 ) && defined( RE_Direct_RectArea )\n\tRectAreaLight rectAreaLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_RECT_AREA_LIGHTS; i ++ ) {\n\t\trectAreaLight = rectAreaLights[ i ];\n\t\tRE_Direct_RectArea( rectAreaLight, geometry, material, reflectedLight );\n\t}\n#endif\n#if defined( RE_IndirectDiffuse )\n\tvec3 irradiance = getAmbientLightIrradiance( ambientLightColor );\n\t#if ( NUM_HEMI_LIGHTS > 0 )\n\t\t#pragma unroll_loop\n\t\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\t\tirradiance += getHemisphereLightIrradiance( hemisphereLights[ i ], geometry );\n\t\t}\n\t#endif\n#endif\n#if defined( RE_IndirectSpecular )\n\tvec3 radiance = vec3( 0.0 );\n\tvec3 clearCoatRadiance = vec3( 0.0 );\n#endif\n"; + + var lights_fragment_maps = "#if defined( RE_IndirectDiffuse )\n\t#ifdef USE_LIGHTMAP\n\t\tvec3 lightMapIrradiance = texture2D( lightMap, vUv2 ).xyz * lightMapIntensity;\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tlightMapIrradiance *= PI;\n\t\t#endif\n\t\tirradiance += lightMapIrradiance;\n\t#endif\n\t#if defined( USE_ENVMAP ) && defined( PHYSICAL ) && defined( ENVMAP_TYPE_CUBE_UV )\n\t\tirradiance += getLightProbeIndirectIrradiance( geometry, maxMipLevel );\n\t#endif\n#endif\n#if defined( USE_ENVMAP ) && defined( RE_IndirectSpecular )\n\tradiance += getLightProbeIndirectRadiance( geometry, Material_BlinnShininessExponent( material ), maxMipLevel );\n\t#ifndef STANDARD\n\t\tclearCoatRadiance += getLightProbeIndirectRadiance( geometry, Material_ClearCoat_BlinnShininessExponent( material ), maxMipLevel );\n\t#endif\n#endif\n"; + + var lights_fragment_end = "#if defined( RE_IndirectDiffuse )\n\tRE_IndirectDiffuse( irradiance, geometry, material, reflectedLight );\n#endif\n#if defined( RE_IndirectSpecular )\n\tRE_IndirectSpecular( radiance, clearCoatRadiance, geometry, material, reflectedLight );\n#endif\n"; + + var logdepthbuf_fragment = "#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tgl_FragDepthEXT = log2( vFragDepth ) * logDepthBufFC * 0.5;\n#endif"; + + var logdepthbuf_pars_fragment = "#ifdef USE_LOGDEPTHBUF\n\tuniform float logDepthBufFC;\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvarying float vFragDepth;\n\t#endif\n#endif\n"; + + var logdepthbuf_pars_vertex = "#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvarying float vFragDepth;\n\t#endif\n\tuniform float logDepthBufFC;\n#endif"; + + var logdepthbuf_vertex = "#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvFragDepth = 1.0 + gl_Position.w;\n\t#else\n\t\tgl_Position.z = log2( max( EPSILON, gl_Position.w + 1.0 ) ) * logDepthBufFC - 1.0;\n\t\tgl_Position.z *= gl_Position.w;\n\t#endif\n#endif\n"; + + var map_fragment = "#ifdef USE_MAP\n\tvec4 texelColor = texture2D( map, vUv );\n\ttexelColor = mapTexelToLinear( texelColor );\n\tdiffuseColor *= texelColor;\n#endif\n"; + + var map_pars_fragment = "#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif\n"; + + var map_particle_fragment = "#ifdef USE_MAP\n\tvec2 uv = ( uvTransform * vec3( gl_PointCoord.x, 1.0 - gl_PointCoord.y, 1 ) ).xy;\n\tvec4 mapTexel = texture2D( map, uv );\n\tdiffuseColor *= mapTexelToLinear( mapTexel );\n#endif\n"; + + var map_particle_pars_fragment = "#ifdef USE_MAP\n\tuniform mat3 uvTransform;\n\tuniform sampler2D map;\n#endif\n"; + + var metalnessmap_fragment = "float metalnessFactor = metalness;\n#ifdef USE_METALNESSMAP\n\tvec4 texelMetalness = texture2D( metalnessMap, vUv );\n\tmetalnessFactor *= texelMetalness.b;\n#endif\n"; + + var metalnessmap_pars_fragment = "#ifdef USE_METALNESSMAP\n\tuniform sampler2D metalnessMap;\n#endif"; + + var morphnormal_vertex = "#ifdef USE_MORPHNORMALS\n\tobjectNormal += ( morphNormal0 - normal ) * morphTargetInfluences[ 0 ];\n\tobjectNormal += ( morphNormal1 - normal ) * morphTargetInfluences[ 1 ];\n\tobjectNormal += ( morphNormal2 - normal ) * morphTargetInfluences[ 2 ];\n\tobjectNormal += ( morphNormal3 - normal ) * morphTargetInfluences[ 3 ];\n#endif\n"; + + var morphtarget_pars_vertex = "#ifdef USE_MORPHTARGETS\n\t#ifndef USE_MORPHNORMALS\n\tuniform float morphTargetInfluences[ 8 ];\n\t#else\n\tuniform float morphTargetInfluences[ 4 ];\n\t#endif\n#endif"; + + var morphtarget_vertex = "#ifdef USE_MORPHTARGETS\n\ttransformed += ( morphTarget0 - position ) * morphTargetInfluences[ 0 ];\n\ttransformed += ( morphTarget1 - position ) * morphTargetInfluences[ 1 ];\n\ttransformed += ( morphTarget2 - position ) * morphTargetInfluences[ 2 ];\n\ttransformed += ( morphTarget3 - position ) * morphTargetInfluences[ 3 ];\n\t#ifndef USE_MORPHNORMALS\n\ttransformed += ( morphTarget4 - position ) * morphTargetInfluences[ 4 ];\n\ttransformed += ( morphTarget5 - position ) * morphTargetInfluences[ 5 ];\n\ttransformed += ( morphTarget6 - position ) * morphTargetInfluences[ 6 ];\n\ttransformed += ( morphTarget7 - position ) * morphTargetInfluences[ 7 ];\n\t#endif\n#endif\n"; + + var normal_fragment_begin = "#ifdef FLAT_SHADED\n\tvec3 fdx = vec3( dFdx( vViewPosition.x ), dFdx( vViewPosition.y ), dFdx( vViewPosition.z ) );\n\tvec3 fdy = vec3( dFdy( vViewPosition.x ), dFdy( vViewPosition.y ), dFdy( vViewPosition.z ) );\n\tvec3 normal = normalize( cross( fdx, fdy ) );\n#else\n\tvec3 normal = normalize( vNormal );\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * ( float( gl_FrontFacing ) * 2.0 - 1.0 );\n\t#endif\n#endif\n"; + + var normal_fragment_maps = "#ifdef USE_NORMALMAP\n\tnormal = perturbNormal2Arb( -vViewPosition, normal );\n#elif defined( USE_BUMPMAP )\n\tnormal = perturbNormalArb( -vViewPosition, normal, dHdxy_fwd() );\n#endif\n"; + + var normalmap_pars_fragment = "#ifdef USE_NORMALMAP\n\tuniform sampler2D normalMap;\n\tuniform vec2 normalScale;\n\tvec3 perturbNormal2Arb( vec3 eye_pos, vec3 surf_norm ) {\n\t\tvec3 q0 = vec3( dFdx( eye_pos.x ), dFdx( eye_pos.y ), dFdx( eye_pos.z ) );\n\t\tvec3 q1 = vec3( dFdy( eye_pos.x ), dFdy( eye_pos.y ), dFdy( eye_pos.z ) );\n\t\tvec2 st0 = dFdx( vUv.st );\n\t\tvec2 st1 = dFdy( vUv.st );\n\t\tfloat scale = sign( st1.t * st0.s - st0.t * st1.s );\n\t\tvec3 S = normalize( ( q0 * st1.t - q1 * st0.t ) * scale );\n\t\tvec3 T = normalize( ( - q0 * st1.s + q1 * st0.s ) * scale );\n\t\tvec3 N = normalize( surf_norm );\n\t\tmat3 tsn = mat3( S, T, N );\n\t\tvec3 mapN = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;\n\t\tmapN.xy *= normalScale;\n\t\tmapN.xy *= ( float( gl_FrontFacing ) * 2.0 - 1.0 );\n\t\treturn normalize( tsn * mapN );\n\t}\n#endif\n"; + + var packing = "vec3 packNormalToRGB( const in vec3 normal ) {\n\treturn normalize( normal ) * 0.5 + 0.5;\n}\nvec3 unpackRGBToNormal( const in vec3 rgb ) {\n\treturn 2.0 * rgb.xyz - 1.0;\n}\nconst float PackUpscale = 256. / 255.;const float UnpackDownscale = 255. / 256.;\nconst vec3 PackFactors = vec3( 256. * 256. * 256., 256. * 256., 256. );\nconst vec4 UnpackFactors = UnpackDownscale / vec4( PackFactors, 1. );\nconst float ShiftRight8 = 1. / 256.;\nvec4 packDepthToRGBA( const in float v ) {\n\tvec4 r = vec4( fract( v * PackFactors ), v );\n\tr.yzw -= r.xyz * ShiftRight8;\treturn r * PackUpscale;\n}\nfloat unpackRGBAToDepth( const in vec4 v ) {\n\treturn dot( v, UnpackFactors );\n}\nfloat viewZToOrthographicDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( viewZ + near ) / ( near - far );\n}\nfloat orthographicDepthToViewZ( const in float linearClipZ, const in float near, const in float far ) {\n\treturn linearClipZ * ( near - far ) - near;\n}\nfloat viewZToPerspectiveDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn (( near + viewZ ) * far ) / (( far - near ) * viewZ );\n}\nfloat perspectiveDepthToViewZ( const in float invClipZ, const in float near, const in float far ) {\n\treturn ( near * far ) / ( ( far - near ) * invClipZ - far );\n}\n"; + + var premultiplied_alpha_fragment = "#ifdef PREMULTIPLIED_ALPHA\n\tgl_FragColor.rgb *= gl_FragColor.a;\n#endif\n"; + + var project_vertex = "vec4 mvPosition = modelViewMatrix * vec4( transformed, 1.0 );\ngl_Position = projectionMatrix * mvPosition;\n"; + + var dithering_fragment = "#if defined( DITHERING )\n gl_FragColor.rgb = dithering( gl_FragColor.rgb );\n#endif\n"; + + var dithering_pars_fragment = "#if defined( DITHERING )\n\tvec3 dithering( vec3 color ) {\n\t\tfloat grid_position = rand( gl_FragCoord.xy );\n\t\tvec3 dither_shift_RGB = vec3( 0.25 / 255.0, -0.25 / 255.0, 0.25 / 255.0 );\n\t\tdither_shift_RGB = mix( 2.0 * dither_shift_RGB, -2.0 * dither_shift_RGB, grid_position );\n\t\treturn color + dither_shift_RGB;\n\t}\n#endif\n"; + + var roughnessmap_fragment = "float roughnessFactor = roughness;\n#ifdef USE_ROUGHNESSMAP\n\tvec4 texelRoughness = texture2D( roughnessMap, vUv );\n\troughnessFactor *= texelRoughness.g;\n#endif\n"; + + var roughnessmap_pars_fragment = "#ifdef USE_ROUGHNESSMAP\n\tuniform sampler2D roughnessMap;\n#endif"; + + var shadowmap_pars_fragment = "#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHTS > 0\n\t\tuniform sampler2D directionalShadowMap[ NUM_DIR_LIGHTS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHTS ];\n\t#endif\n\t#if NUM_SPOT_LIGHTS > 0\n\t\tuniform sampler2D spotShadowMap[ NUM_SPOT_LIGHTS ];\n\t\tvarying vec4 vSpotShadowCoord[ NUM_SPOT_LIGHTS ];\n\t#endif\n\t#if NUM_POINT_LIGHTS > 0\n\t\tuniform sampler2D pointShadowMap[ NUM_POINT_LIGHTS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHTS ];\n\t#endif\n\tfloat texture2DCompare( sampler2D depths, vec2 uv, float compare ) {\n\t\treturn step( compare, unpackRGBAToDepth( texture2D( depths, uv ) ) );\n\t}\n\tfloat texture2DShadowLerp( sampler2D depths, vec2 size, vec2 uv, float compare ) {\n\t\tconst vec2 offset = vec2( 0.0, 1.0 );\n\t\tvec2 texelSize = vec2( 1.0 ) / size;\n\t\tvec2 centroidUV = floor( uv * size + 0.5 ) / size;\n\t\tfloat lb = texture2DCompare( depths, centroidUV + texelSize * offset.xx, compare );\n\t\tfloat lt = texture2DCompare( depths, centroidUV + texelSize * offset.xy, compare );\n\t\tfloat rb = texture2DCompare( depths, centroidUV + texelSize * offset.yx, compare );\n\t\tfloat rt = texture2DCompare( depths, centroidUV + texelSize * offset.yy, compare );\n\t\tvec2 f = fract( uv * size + 0.5 );\n\t\tfloat a = mix( lb, lt, f.y );\n\t\tfloat b = mix( rb, rt, f.y );\n\t\tfloat c = mix( a, b, f.x );\n\t\treturn c;\n\t}\n\tfloat getShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord ) {\n\t\tfloat shadow = 1.0;\n\t\tshadowCoord.xyz /= shadowCoord.w;\n\t\tshadowCoord.z += shadowBias;\n\t\tbvec4 inFrustumVec = bvec4 ( shadowCoord.x >= 0.0, shadowCoord.x <= 1.0, shadowCoord.y >= 0.0, shadowCoord.y <= 1.0 );\n\t\tbool inFrustum = all( inFrustumVec );\n\t\tbvec2 frustumTestVec = bvec2( inFrustum, shadowCoord.z <= 1.0 );\n\t\tbool frustumTest = all( frustumTestVec );\n\t\tif ( frustumTest ) {\n\t\t#if defined( SHADOWMAP_TYPE_PCF )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx0 = - texelSize.x * shadowRadius;\n\t\t\tfloat dy0 = - texelSize.y * shadowRadius;\n\t\t\tfloat dx1 = + texelSize.x * shadowRadius;\n\t\t\tfloat dy1 = + texelSize.y * shadowRadius;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy1 ), shadowCoord.z )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_PCF_SOFT )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx0 = - texelSize.x * shadowRadius;\n\t\t\tfloat dy0 = - texelSize.y * shadowRadius;\n\t\t\tfloat dx1 = + texelSize.x * shadowRadius;\n\t\t\tfloat dy1 = + texelSize.y * shadowRadius;\n\t\t\tshadow = (\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( 0.0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx1, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx0, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy, shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx1, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( 0.0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx1, dy1 ), shadowCoord.z )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#else\n\t\t\tshadow = texture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#endif\n\t\t}\n\t\treturn shadow;\n\t}\n\tvec2 cubeToUV( vec3 v, float texelSizeY ) {\n\t\tvec3 absV = abs( v );\n\t\tfloat scaleToCube = 1.0 / max( absV.x, max( absV.y, absV.z ) );\n\t\tabsV *= scaleToCube;\n\t\tv *= scaleToCube * ( 1.0 - 2.0 * texelSizeY );\n\t\tvec2 planar = v.xy;\n\t\tfloat almostATexel = 1.5 * texelSizeY;\n\t\tfloat almostOne = 1.0 - almostATexel;\n\t\tif ( absV.z >= almostOne ) {\n\t\t\tif ( v.z > 0.0 )\n\t\t\t\tplanar.x = 4.0 - v.x;\n\t\t} else if ( absV.x >= almostOne ) {\n\t\t\tfloat signX = sign( v.x );\n\t\t\tplanar.x = v.z * signX + 2.0 * signX;\n\t\t} else if ( absV.y >= almostOne ) {\n\t\t\tfloat signY = sign( v.y );\n\t\t\tplanar.x = v.x + 2.0 * signY + 2.0;\n\t\t\tplanar.y = v.z * signY - 2.0;\n\t\t}\n\t\treturn vec2( 0.125, 0.25 ) * planar + vec2( 0.375, 0.75 );\n\t}\n\tfloat getPointShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord, float shadowCameraNear, float shadowCameraFar ) {\n\t\tvec2 texelSize = vec2( 1.0 ) / ( shadowMapSize * vec2( 4.0, 2.0 ) );\n\t\tvec3 lightToPosition = shadowCoord.xyz;\n\t\tfloat dp = ( length( lightToPosition ) - shadowCameraNear ) / ( shadowCameraFar - shadowCameraNear );\t\tdp += shadowBias;\n\t\tvec3 bd3D = normalize( lightToPosition );\n\t\t#if defined( SHADOWMAP_TYPE_PCF ) || defined( SHADOWMAP_TYPE_PCF_SOFT )\n\t\t\tvec2 offset = vec2( - 1, 1 ) * shadowRadius * texelSize.y;\n\t\t\treturn (\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxx, texelSize.y ), dp )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#else\n\t\t\treturn texture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp );\n\t\t#endif\n\t}\n#endif\n"; + + var shadowmap_pars_vertex = "#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHTS > 0\n\t\tuniform mat4 directionalShadowMatrix[ NUM_DIR_LIGHTS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHTS ];\n\t#endif\n\t#if NUM_SPOT_LIGHTS > 0\n\t\tuniform mat4 spotShadowMatrix[ NUM_SPOT_LIGHTS ];\n\t\tvarying vec4 vSpotShadowCoord[ NUM_SPOT_LIGHTS ];\n\t#endif\n\t#if NUM_POINT_LIGHTS > 0\n\t\tuniform mat4 pointShadowMatrix[ NUM_POINT_LIGHTS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHTS ];\n\t#endif\n#endif\n"; + + var shadowmap_vertex = "#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tvDirectionalShadowCoord[ i ] = directionalShadowMatrix[ i ] * worldPosition;\n\t}\n\t#endif\n\t#if NUM_SPOT_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tvSpotShadowCoord[ i ] = spotShadowMatrix[ i ] * worldPosition;\n\t}\n\t#endif\n\t#if NUM_POINT_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tvPointShadowCoord[ i ] = pointShadowMatrix[ i ] * worldPosition;\n\t}\n\t#endif\n#endif\n"; + + var shadowmask_pars_fragment = "float getShadowMask() {\n\tfloat shadow = 1.0;\n\t#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHTS > 0\n\tDirectionalLight directionalLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tdirectionalLight = directionalLights[ i ];\n\t\tshadow *= bool( directionalLight.shadow ) ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t}\n\t#endif\n\t#if NUM_SPOT_LIGHTS > 0\n\tSpotLight spotLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tspotLight = spotLights[ i ];\n\t\tshadow *= bool( spotLight.shadow ) ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowBias, spotLight.shadowRadius, vSpotShadowCoord[ i ] ) : 1.0;\n\t}\n\t#endif\n\t#if NUM_POINT_LIGHTS > 0\n\tPointLight pointLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tpointLight = pointLights[ i ];\n\t\tshadow *= bool( pointLight.shadow ) ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0;\n\t}\n\t#endif\n\t#endif\n\treturn shadow;\n}\n"; + + var skinbase_vertex = "#ifdef USE_SKINNING\n\tmat4 boneMatX = getBoneMatrix( skinIndex.x );\n\tmat4 boneMatY = getBoneMatrix( skinIndex.y );\n\tmat4 boneMatZ = getBoneMatrix( skinIndex.z );\n\tmat4 boneMatW = getBoneMatrix( skinIndex.w );\n#endif"; + + var skinning_pars_vertex = "#ifdef USE_SKINNING\n\tuniform mat4 bindMatrix;\n\tuniform mat4 bindMatrixInverse;\n\t#ifdef BONE_TEXTURE\n\t\tuniform sampler2D boneTexture;\n\t\tuniform int boneTextureSize;\n\t\tmat4 getBoneMatrix( const in float i ) {\n\t\t\tfloat j = i * 4.0;\n\t\t\tfloat x = mod( j, float( boneTextureSize ) );\n\t\t\tfloat y = floor( j / float( boneTextureSize ) );\n\t\t\tfloat dx = 1.0 / float( boneTextureSize );\n\t\t\tfloat dy = 1.0 / float( boneTextureSize );\n\t\t\ty = dy * ( y + 0.5 );\n\t\t\tvec4 v1 = texture2D( boneTexture, vec2( dx * ( x + 0.5 ), y ) );\n\t\t\tvec4 v2 = texture2D( boneTexture, vec2( dx * ( x + 1.5 ), y ) );\n\t\t\tvec4 v3 = texture2D( boneTexture, vec2( dx * ( x + 2.5 ), y ) );\n\t\t\tvec4 v4 = texture2D( boneTexture, vec2( dx * ( x + 3.5 ), y ) );\n\t\t\tmat4 bone = mat4( v1, v2, v3, v4 );\n\t\t\treturn bone;\n\t\t}\n\t#else\n\t\tuniform mat4 boneMatrices[ MAX_BONES ];\n\t\tmat4 getBoneMatrix( const in float i ) {\n\t\t\tmat4 bone = boneMatrices[ int(i) ];\n\t\t\treturn bone;\n\t\t}\n\t#endif\n#endif\n"; + + var skinning_vertex = "#ifdef USE_SKINNING\n\tvec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );\n\tvec4 skinned = vec4( 0.0 );\n\tskinned += boneMatX * skinVertex * skinWeight.x;\n\tskinned += boneMatY * skinVertex * skinWeight.y;\n\tskinned += boneMatZ * skinVertex * skinWeight.z;\n\tskinned += boneMatW * skinVertex * skinWeight.w;\n\ttransformed = ( bindMatrixInverse * skinned ).xyz;\n#endif\n"; + + var skinnormal_vertex = "#ifdef USE_SKINNING\n\tmat4 skinMatrix = mat4( 0.0 );\n\tskinMatrix += skinWeight.x * boneMatX;\n\tskinMatrix += skinWeight.y * boneMatY;\n\tskinMatrix += skinWeight.z * boneMatZ;\n\tskinMatrix += skinWeight.w * boneMatW;\n\tskinMatrix = bindMatrixInverse * skinMatrix * bindMatrix;\n\tobjectNormal = vec4( skinMatrix * vec4( objectNormal, 0.0 ) ).xyz;\n#endif\n"; + + var specularmap_fragment = "float specularStrength;\n#ifdef USE_SPECULARMAP\n\tvec4 texelSpecular = texture2D( specularMap, vUv );\n\tspecularStrength = texelSpecular.r;\n#else\n\tspecularStrength = 1.0;\n#endif"; + + var specularmap_pars_fragment = "#ifdef USE_SPECULARMAP\n\tuniform sampler2D specularMap;\n#endif"; + + var tonemapping_fragment = "#if defined( TONE_MAPPING )\n gl_FragColor.rgb = toneMapping( gl_FragColor.rgb );\n#endif\n"; + + var tonemapping_pars_fragment = "#ifndef saturate\n\t#define saturate(a) clamp( a, 0.0, 1.0 )\n#endif\nuniform float toneMappingExposure;\nuniform float toneMappingWhitePoint;\nvec3 LinearToneMapping( vec3 color ) {\n\treturn toneMappingExposure * color;\n}\nvec3 ReinhardToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\treturn saturate( color / ( vec3( 1.0 ) + color ) );\n}\n#define Uncharted2Helper( x ) max( ( ( x * ( 0.15 * x + 0.10 * 0.50 ) + 0.20 * 0.02 ) / ( x * ( 0.15 * x + 0.50 ) + 0.20 * 0.30 ) ) - 0.02 / 0.30, vec3( 0.0 ) )\nvec3 Uncharted2ToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\treturn saturate( Uncharted2Helper( color ) / Uncharted2Helper( vec3( toneMappingWhitePoint ) ) );\n}\nvec3 OptimizedCineonToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\tcolor = max( vec3( 0.0 ), color - 0.004 );\n\treturn pow( ( color * ( 6.2 * color + 0.5 ) ) / ( color * ( 6.2 * color + 1.7 ) + 0.06 ), vec3( 2.2 ) );\n}\n"; + + var uv_pars_fragment = "#if defined( USE_MAP ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( USE_SPECULARMAP ) || defined( USE_ALPHAMAP ) || defined( USE_EMISSIVEMAP ) || defined( USE_ROUGHNESSMAP ) || defined( USE_METALNESSMAP )\n\tvarying vec2 vUv;\n#endif"; + + var uv_pars_vertex = "#if defined( USE_MAP ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( USE_SPECULARMAP ) || defined( USE_ALPHAMAP ) || defined( USE_EMISSIVEMAP ) || defined( USE_ROUGHNESSMAP ) || defined( USE_METALNESSMAP )\n\tvarying vec2 vUv;\n\tuniform mat3 uvTransform;\n#endif\n"; + + var uv_vertex = "#if defined( USE_MAP ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( USE_SPECULARMAP ) || defined( USE_ALPHAMAP ) || defined( USE_EMISSIVEMAP ) || defined( USE_ROUGHNESSMAP ) || defined( USE_METALNESSMAP )\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n#endif"; + + var uv2_pars_fragment = "#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tvarying vec2 vUv2;\n#endif"; + + var uv2_pars_vertex = "#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tattribute vec2 uv2;\n\tvarying vec2 vUv2;\n#endif"; + + var uv2_vertex = "#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tvUv2 = uv2;\n#endif"; + + var worldpos_vertex = "#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP )\n\tvec4 worldPosition = modelMatrix * vec4( transformed, 1.0 );\n#endif\n"; + + var cube_frag = "uniform samplerCube tCube;\nuniform float tFlip;\nuniform float opacity;\nvarying vec3 vWorldPosition;\nvoid main() {\n\tgl_FragColor = textureCube( tCube, vec3( tFlip * vWorldPosition.x, vWorldPosition.yz ) );\n\tgl_FragColor.a *= opacity;\n}\n"; + + var cube_vert = "varying vec3 vWorldPosition;\n#include \nvoid main() {\n\tvWorldPosition = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n\tgl_Position.z = gl_Position.w;\n}\n"; + + var depth_frag = "#if DEPTH_PACKING == 3200\n\tuniform float opacity;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#if DEPTH_PACKING == 3200\n\t\tdiffuseColor.a = opacity;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#if DEPTH_PACKING == 3200\n\t\tgl_FragColor = vec4( vec3( 1.0 - gl_FragCoord.z ), opacity );\n\t#elif DEPTH_PACKING == 3201\n\t\tgl_FragColor = packDepthToRGBA( gl_FragCoord.z );\n\t#endif\n}\n"; + + var depth_vert = "#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var distanceRGBA_frag = "#define DISTANCE\nuniform vec3 referencePosition;\nuniform float nearDistance;\nuniform float farDistance;\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main () {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#include \n\t#include \n\t#include \n\tfloat dist = length( vWorldPosition - referencePosition );\n\tdist = ( dist - nearDistance ) / ( farDistance - nearDistance );\n\tdist = saturate( dist );\n\tgl_FragColor = packDepthToRGBA( dist );\n}\n"; + + var distanceRGBA_vert = "#define DISTANCE\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvWorldPosition = worldPosition.xyz;\n}\n"; + + var equirect_frag = "uniform sampler2D tEquirect;\nvarying vec3 vWorldPosition;\n#include \nvoid main() {\n\tvec3 direction = normalize( vWorldPosition );\n\tvec2 sampleUV;\n\tsampleUV.y = asin( clamp( direction.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\tsampleUV.x = atan( direction.z, direction.x ) * RECIPROCAL_PI2 + 0.5;\n\tgl_FragColor = texture2D( tEquirect, sampleUV );\n}\n"; + + var equirect_vert = "varying vec3 vWorldPosition;\n#include \nvoid main() {\n\tvWorldPosition = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n}\n"; + + var linedashed_frag = "uniform vec3 diffuse;\nuniform float opacity;\nuniform float dashSize;\nuniform float totalSize;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tif ( mod( vLineDistance, totalSize ) > dashSize ) {\n\t\tdiscard;\n\t}\n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var linedashed_vert = "uniform float scale;\nattribute float lineDistance;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvLineDistance = scale * lineDistance;\n\tvec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );\n\tgl_Position = projectionMatrix * mvPosition;\n\t#include \n\t#include \n\t#include \n}\n"; + + var meshbasic_frag = "uniform vec3 diffuse;\nuniform float opacity;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\t#ifdef USE_LIGHTMAP\n\t\treflectedLight.indirectDiffuse += texture2D( lightMap, vUv2 ).xyz * lightMapIntensity;\n\t#else\n\t\treflectedLight.indirectDiffuse += vec3( 1.0 );\n\t#endif\n\t#include \n\treflectedLight.indirectDiffuse *= diffuseColor.rgb;\n\tvec3 outgoingLight = reflectedLight.indirectDiffuse;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var meshbasic_vert = "#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifdef USE_ENVMAP\n\t#include \n\t#include \n\t#include \n\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var meshlambert_frag = "uniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\nvarying vec3 vLightFront;\n#ifdef DOUBLE_SIDED\n\tvarying vec3 vLightBack;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\treflectedLight.indirectDiffuse = getAmbientLightIrradiance( ambientLightColor );\n\t#include \n\treflectedLight.indirectDiffuse *= BRDF_Diffuse_Lambert( diffuseColor.rgb );\n\t#ifdef DOUBLE_SIDED\n\t\treflectedLight.directDiffuse = ( gl_FrontFacing ) ? vLightFront : vLightBack;\n\t#else\n\t\treflectedLight.directDiffuse = vLightFront;\n\t#endif\n\treflectedLight.directDiffuse *= BRDF_Diffuse_Lambert( diffuseColor.rgb ) * getShadowMask();\n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var meshlambert_vert = "#define LAMBERT\nvarying vec3 vLightFront;\n#ifdef DOUBLE_SIDED\n\tvarying vec3 vLightBack;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var meshphong_frag = "#define PHONG\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform vec3 specular;\nuniform float shininess;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var meshphong_vert = "#define PHONG\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var meshphysical_frag = "#define PHYSICAL\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float roughness;\nuniform float metalness;\nuniform float opacity;\n#ifndef STANDARD\n\tuniform float clearCoat;\n\tuniform float clearCoatRoughness;\n#endif\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var meshphysical_vert = "#define PHYSICAL\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n}\n"; + + var normal_frag = "#define NORMAL\nuniform float opacity;\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP )\n\tvarying vec3 vViewPosition;\n#endif\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\tgl_FragColor = vec4( packNormalToRGB( normal ), opacity );\n}\n"; + + var normal_vert = "#define NORMAL\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP )\n\tvarying vec3 vViewPosition;\n#endif\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP )\n\tvViewPosition = - mvPosition.xyz;\n#endif\n}\n"; + + var points_frag = "uniform vec3 diffuse;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var points_vert = "uniform float size;\nuniform float scale;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifdef USE_SIZEATTENUATION\n\t\tgl_PointSize = size * ( scale / - mvPosition.z );\n\t#else\n\t\tgl_PointSize = size;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var shadow_frag = "uniform vec3 color;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\tgl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );\n\t#include \n}\n"; + + var shadow_vert = "#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n"; + + var ShaderChunk = { + alphamap_fragment: alphamap_fragment, + alphamap_pars_fragment: alphamap_pars_fragment, + alphatest_fragment: alphatest_fragment, + aomap_fragment: aomap_fragment, + aomap_pars_fragment: aomap_pars_fragment, + begin_vertex: begin_vertex, + beginnormal_vertex: beginnormal_vertex, + bsdfs: bsdfs, + bumpmap_pars_fragment: bumpmap_pars_fragment, + clipping_planes_fragment: clipping_planes_fragment, + clipping_planes_pars_fragment: clipping_planes_pars_fragment, + clipping_planes_pars_vertex: clipping_planes_pars_vertex, + clipping_planes_vertex: clipping_planes_vertex, + color_fragment: color_fragment, + color_pars_fragment: color_pars_fragment, + color_pars_vertex: color_pars_vertex, + color_vertex: color_vertex, + common: common, + cube_uv_reflection_fragment: cube_uv_reflection_fragment, + defaultnormal_vertex: defaultnormal_vertex, + displacementmap_pars_vertex: displacementmap_pars_vertex, + displacementmap_vertex: displacementmap_vertex, + emissivemap_fragment: emissivemap_fragment, + emissivemap_pars_fragment: emissivemap_pars_fragment, + encodings_fragment: encodings_fragment, + encodings_pars_fragment: encodings_pars_fragment, + envmap_fragment: envmap_fragment, + envmap_pars_fragment: envmap_pars_fragment, + envmap_pars_vertex: envmap_pars_vertex, + envmap_vertex: envmap_vertex, + fog_vertex: fog_vertex, + fog_pars_vertex: fog_pars_vertex, + fog_fragment: fog_fragment, + fog_pars_fragment: fog_pars_fragment, + gradientmap_pars_fragment: gradientmap_pars_fragment, + lightmap_fragment: lightmap_fragment, + lightmap_pars_fragment: lightmap_pars_fragment, + lights_lambert_vertex: lights_lambert_vertex, + lights_pars_begin: lights_pars_begin, + lights_pars_maps: lights_pars_maps, + lights_phong_fragment: lights_phong_fragment, + lights_phong_pars_fragment: lights_phong_pars_fragment, + lights_physical_fragment: lights_physical_fragment, + lights_physical_pars_fragment: lights_physical_pars_fragment, + lights_fragment_begin: lights_fragment_begin, + lights_fragment_maps: lights_fragment_maps, + lights_fragment_end: lights_fragment_end, + logdepthbuf_fragment: logdepthbuf_fragment, + logdepthbuf_pars_fragment: logdepthbuf_pars_fragment, + logdepthbuf_pars_vertex: logdepthbuf_pars_vertex, + logdepthbuf_vertex: logdepthbuf_vertex, + map_fragment: map_fragment, + map_pars_fragment: map_pars_fragment, + map_particle_fragment: map_particle_fragment, + map_particle_pars_fragment: map_particle_pars_fragment, + metalnessmap_fragment: metalnessmap_fragment, + metalnessmap_pars_fragment: metalnessmap_pars_fragment, + morphnormal_vertex: morphnormal_vertex, + morphtarget_pars_vertex: morphtarget_pars_vertex, + morphtarget_vertex: morphtarget_vertex, + normal_fragment_begin: normal_fragment_begin, + normal_fragment_maps: normal_fragment_maps, + normalmap_pars_fragment: normalmap_pars_fragment, + packing: packing, + premultiplied_alpha_fragment: premultiplied_alpha_fragment, + project_vertex: project_vertex, + dithering_fragment: dithering_fragment, + dithering_pars_fragment: dithering_pars_fragment, + roughnessmap_fragment: roughnessmap_fragment, + roughnessmap_pars_fragment: roughnessmap_pars_fragment, + shadowmap_pars_fragment: shadowmap_pars_fragment, + shadowmap_pars_vertex: shadowmap_pars_vertex, + shadowmap_vertex: shadowmap_vertex, + shadowmask_pars_fragment: shadowmask_pars_fragment, + skinbase_vertex: skinbase_vertex, + skinning_pars_vertex: skinning_pars_vertex, + skinning_vertex: skinning_vertex, + skinnormal_vertex: skinnormal_vertex, + specularmap_fragment: specularmap_fragment, + specularmap_pars_fragment: specularmap_pars_fragment, + tonemapping_fragment: tonemapping_fragment, + tonemapping_pars_fragment: tonemapping_pars_fragment, + uv_pars_fragment: uv_pars_fragment, + uv_pars_vertex: uv_pars_vertex, + uv_vertex: uv_vertex, + uv2_pars_fragment: uv2_pars_fragment, + uv2_pars_vertex: uv2_pars_vertex, + uv2_vertex: uv2_vertex, + worldpos_vertex: worldpos_vertex, + + cube_frag: cube_frag, + cube_vert: cube_vert, + depth_frag: depth_frag, + depth_vert: depth_vert, + distanceRGBA_frag: distanceRGBA_frag, + distanceRGBA_vert: distanceRGBA_vert, + equirect_frag: equirect_frag, + equirect_vert: equirect_vert, + linedashed_frag: linedashed_frag, + linedashed_vert: linedashed_vert, + meshbasic_frag: meshbasic_frag, + meshbasic_vert: meshbasic_vert, + meshlambert_frag: meshlambert_frag, + meshlambert_vert: meshlambert_vert, + meshphong_frag: meshphong_frag, + meshphong_vert: meshphong_vert, + meshphysical_frag: meshphysical_frag, + meshphysical_vert: meshphysical_vert, + normal_frag: normal_frag, + normal_vert: normal_vert, + points_frag: points_frag, + points_vert: points_vert, + shadow_frag: shadow_frag, + shadow_vert: shadow_vert + }; + + /** + * Uniform Utilities + */ + + var UniformsUtils = { + + merge: function ( uniforms ) { + + var merged = {}; + + for ( var u = 0; u < uniforms.length; u ++ ) { + + var tmp = this.clone( uniforms[ u ] ); + + for ( var p in tmp ) { + + merged[ p ] = tmp[ p ]; + + } + + } + + return merged; + + }, + + clone: function ( uniforms_src ) { + + var uniforms_dst = {}; + + for ( var u in uniforms_src ) { + + uniforms_dst[ u ] = {}; + + for ( var p in uniforms_src[ u ] ) { + + var parameter_src = uniforms_src[ u ][ p ]; + + if ( parameter_src && ( parameter_src.isColor || + parameter_src.isMatrix3 || parameter_src.isMatrix4 || + parameter_src.isVector2 || parameter_src.isVector3 || parameter_src.isVector4 || + parameter_src.isTexture ) ) { + + uniforms_dst[ u ][ p ] = parameter_src.clone(); + + } else if ( Array.isArray( parameter_src ) ) { + + uniforms_dst[ u ][ p ] = parameter_src.slice(); + + } else { + + uniforms_dst[ u ][ p ] = parameter_src; + + } + + } + + } + + return uniforms_dst; + + } + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + var ColorKeywords = { 'aliceblue': 0xF0F8FF, 'antiquewhite': 0xFAEBD7, 'aqua': 0x00FFFF, 'aquamarine': 0x7FFFD4, 'azure': 0xF0FFFF, + 'beige': 0xF5F5DC, 'bisque': 0xFFE4C4, 'black': 0x000000, 'blanchedalmond': 0xFFEBCD, 'blue': 0x0000FF, 'blueviolet': 0x8A2BE2, + 'brown': 0xA52A2A, 'burlywood': 0xDEB887, 'cadetblue': 0x5F9EA0, 'chartreuse': 0x7FFF00, 'chocolate': 0xD2691E, 'coral': 0xFF7F50, + 'cornflowerblue': 0x6495ED, 'cornsilk': 0xFFF8DC, 'crimson': 0xDC143C, 'cyan': 0x00FFFF, 'darkblue': 0x00008B, 'darkcyan': 0x008B8B, + 'darkgoldenrod': 0xB8860B, 'darkgray': 0xA9A9A9, 'darkgreen': 0x006400, 'darkgrey': 0xA9A9A9, 'darkkhaki': 0xBDB76B, 'darkmagenta': 0x8B008B, + 'darkolivegreen': 0x556B2F, 'darkorange': 0xFF8C00, 'darkorchid': 0x9932CC, 'darkred': 0x8B0000, 'darksalmon': 0xE9967A, 'darkseagreen': 0x8FBC8F, + 'darkslateblue': 0x483D8B, 'darkslategray': 0x2F4F4F, 'darkslategrey': 0x2F4F4F, 'darkturquoise': 0x00CED1, 'darkviolet': 0x9400D3, + 'deeppink': 0xFF1493, 'deepskyblue': 0x00BFFF, 'dimgray': 0x696969, 'dimgrey': 0x696969, 'dodgerblue': 0x1E90FF, 'firebrick': 0xB22222, + 'floralwhite': 0xFFFAF0, 'forestgreen': 0x228B22, 'fuchsia': 0xFF00FF, 'gainsboro': 0xDCDCDC, 'ghostwhite': 0xF8F8FF, 'gold': 0xFFD700, + 'goldenrod': 0xDAA520, 'gray': 0x808080, 'green': 0x008000, 'greenyellow': 0xADFF2F, 'grey': 0x808080, 'honeydew': 0xF0FFF0, 'hotpink': 0xFF69B4, + 'indianred': 0xCD5C5C, 'indigo': 0x4B0082, 'ivory': 0xFFFFF0, 'khaki': 0xF0E68C, 'lavender': 0xE6E6FA, 'lavenderblush': 0xFFF0F5, 'lawngreen': 0x7CFC00, + 'lemonchiffon': 0xFFFACD, 'lightblue': 0xADD8E6, 'lightcoral': 0xF08080, 'lightcyan': 0xE0FFFF, 'lightgoldenrodyellow': 0xFAFAD2, 'lightgray': 0xD3D3D3, + 'lightgreen': 0x90EE90, 'lightgrey': 0xD3D3D3, 'lightpink': 0xFFB6C1, 'lightsalmon': 0xFFA07A, 'lightseagreen': 0x20B2AA, 'lightskyblue': 0x87CEFA, + 'lightslategray': 0x778899, 'lightslategrey': 0x778899, 'lightsteelblue': 0xB0C4DE, 'lightyellow': 0xFFFFE0, 'lime': 0x00FF00, 'limegreen': 0x32CD32, + 'linen': 0xFAF0E6, 'magenta': 0xFF00FF, 'maroon': 0x800000, 'mediumaquamarine': 0x66CDAA, 'mediumblue': 0x0000CD, 'mediumorchid': 0xBA55D3, + 'mediumpurple': 0x9370DB, 'mediumseagreen': 0x3CB371, 'mediumslateblue': 0x7B68EE, 'mediumspringgreen': 0x00FA9A, 'mediumturquoise': 0x48D1CC, + 'mediumvioletred': 0xC71585, 'midnightblue': 0x191970, 'mintcream': 0xF5FFFA, 'mistyrose': 0xFFE4E1, 'moccasin': 0xFFE4B5, 'navajowhite': 0xFFDEAD, + 'navy': 0x000080, 'oldlace': 0xFDF5E6, 'olive': 0x808000, 'olivedrab': 0x6B8E23, 'orange': 0xFFA500, 'orangered': 0xFF4500, 'orchid': 0xDA70D6, + 'palegoldenrod': 0xEEE8AA, 'palegreen': 0x98FB98, 'paleturquoise': 0xAFEEEE, 'palevioletred': 0xDB7093, 'papayawhip': 0xFFEFD5, 'peachpuff': 0xFFDAB9, + 'peru': 0xCD853F, 'pink': 0xFFC0CB, 'plum': 0xDDA0DD, 'powderblue': 0xB0E0E6, 'purple': 0x800080, 'rebeccapurple': 0x663399, 'red': 0xFF0000, 'rosybrown': 0xBC8F8F, + 'royalblue': 0x4169E1, 'saddlebrown': 0x8B4513, 'salmon': 0xFA8072, 'sandybrown': 0xF4A460, 'seagreen': 0x2E8B57, 'seashell': 0xFFF5EE, + 'sienna': 0xA0522D, 'silver': 0xC0C0C0, 'skyblue': 0x87CEEB, 'slateblue': 0x6A5ACD, 'slategray': 0x708090, 'slategrey': 0x708090, 'snow': 0xFFFAFA, + 'springgreen': 0x00FF7F, 'steelblue': 0x4682B4, 'tan': 0xD2B48C, 'teal': 0x008080, 'thistle': 0xD8BFD8, 'tomato': 0xFF6347, 'turquoise': 0x40E0D0, + 'violet': 0xEE82EE, 'wheat': 0xF5DEB3, 'white': 0xFFFFFF, 'whitesmoke': 0xF5F5F5, 'yellow': 0xFFFF00, 'yellowgreen': 0x9ACD32 }; + + function Color( r, g, b ) { + + if ( g === undefined && b === undefined ) { + + // r is THREE.Color, hex or string + return this.set( r ); + + } + + return this.setRGB( r, g, b ); + + } + + Object.assign( Color.prototype, { + + isColor: true, + + r: 1, g: 1, b: 1, + + set: function ( value ) { + + if ( value && value.isColor ) { + + this.copy( value ); + + } else if ( typeof value === 'number' ) { + + this.setHex( value ); + + } else if ( typeof value === 'string' ) { + + this.setStyle( value ); + + } + + return this; + + }, + + setScalar: function ( scalar ) { + + this.r = scalar; + this.g = scalar; + this.b = scalar; + + return this; + + }, + + setHex: function ( hex ) { + + hex = Math.floor( hex ); + + this.r = ( hex >> 16 & 255 ) / 255; + this.g = ( hex >> 8 & 255 ) / 255; + this.b = ( hex & 255 ) / 255; + + return this; + + }, + + setRGB: function ( r, g, b ) { + + this.r = r; + this.g = g; + this.b = b; + + return this; + + }, + + setHSL: function () { + + function hue2rgb( p, q, t ) { + + if ( t < 0 ) t += 1; + if ( t > 1 ) t -= 1; + if ( t < 1 / 6 ) return p + ( q - p ) * 6 * t; + if ( t < 1 / 2 ) return q; + if ( t < 2 / 3 ) return p + ( q - p ) * 6 * ( 2 / 3 - t ); + return p; + + } + + return function setHSL( h, s, l ) { + + // h,s,l ranges are in 0.0 - 1.0 + h = _Math.euclideanModulo( h, 1 ); + s = _Math.clamp( s, 0, 1 ); + l = _Math.clamp( l, 0, 1 ); + + if ( s === 0 ) { + + this.r = this.g = this.b = l; + + } else { + + var p = l <= 0.5 ? l * ( 1 + s ) : l + s - ( l * s ); + var q = ( 2 * l ) - p; + + this.r = hue2rgb( q, p, h + 1 / 3 ); + this.g = hue2rgb( q, p, h ); + this.b = hue2rgb( q, p, h - 1 / 3 ); + + } + + return this; + + }; + + }(), + + setStyle: function ( style ) { + + function handleAlpha( string ) { + + if ( string === undefined ) return; + + if ( parseFloat( string ) < 1 ) { + + console.warn( 'THREE.Color: Alpha component of ' + style + ' will be ignored.' ); + + } + + } + + + var m; + + if ( m = /^((?:rgb|hsl)a?)\(\s*([^\)]*)\)/.exec( style ) ) { + + // rgb / hsl + + var color; + var name = m[ 1 ]; + var components = m[ 2 ]; + + switch ( name ) { + + case 'rgb': + case 'rgba': + + if ( color = /^(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(,\s*([0-9]*\.?[0-9]+)\s*)?$/.exec( components ) ) { + + // rgb(255,0,0) rgba(255,0,0,0.5) + this.r = Math.min( 255, parseInt( color[ 1 ], 10 ) ) / 255; + this.g = Math.min( 255, parseInt( color[ 2 ], 10 ) ) / 255; + this.b = Math.min( 255, parseInt( color[ 3 ], 10 ) ) / 255; + + handleAlpha( color[ 5 ] ); + + return this; + + } + + if ( color = /^(\d+)\%\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(,\s*([0-9]*\.?[0-9]+)\s*)?$/.exec( components ) ) { + + // rgb(100%,0%,0%) rgba(100%,0%,0%,0.5) + this.r = Math.min( 100, parseInt( color[ 1 ], 10 ) ) / 100; + this.g = Math.min( 100, parseInt( color[ 2 ], 10 ) ) / 100; + this.b = Math.min( 100, parseInt( color[ 3 ], 10 ) ) / 100; + + handleAlpha( color[ 5 ] ); + + return this; + + } + + break; + + case 'hsl': + case 'hsla': + + if ( color = /^([0-9]*\.?[0-9]+)\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(,\s*([0-9]*\.?[0-9]+)\s*)?$/.exec( components ) ) { + + // hsl(120,50%,50%) hsla(120,50%,50%,0.5) + var h = parseFloat( color[ 1 ] ) / 360; + var s = parseInt( color[ 2 ], 10 ) / 100; + var l = parseInt( color[ 3 ], 10 ) / 100; + + handleAlpha( color[ 5 ] ); + + return this.setHSL( h, s, l ); + + } + + break; + + } + + } else if ( m = /^\#([A-Fa-f0-9]+)$/.exec( style ) ) { + + // hex color + + var hex = m[ 1 ]; + var size = hex.length; + + if ( size === 3 ) { + + // #ff0 + this.r = parseInt( hex.charAt( 0 ) + hex.charAt( 0 ), 16 ) / 255; + this.g = parseInt( hex.charAt( 1 ) + hex.charAt( 1 ), 16 ) / 255; + this.b = parseInt( hex.charAt( 2 ) + hex.charAt( 2 ), 16 ) / 255; + + return this; + + } else if ( size === 6 ) { + + // #ff0000 + this.r = parseInt( hex.charAt( 0 ) + hex.charAt( 1 ), 16 ) / 255; + this.g = parseInt( hex.charAt( 2 ) + hex.charAt( 3 ), 16 ) / 255; + this.b = parseInt( hex.charAt( 4 ) + hex.charAt( 5 ), 16 ) / 255; + + return this; + + } + + } + + if ( style && style.length > 0 ) { + + // color keywords + var hex = ColorKeywords[ style ]; + + if ( hex !== undefined ) { + + // red + this.setHex( hex ); + + } else { + + // unknown color + console.warn( 'THREE.Color: Unknown color ' + style ); + + } + + } + + return this; + + }, + + clone: function () { + + return new this.constructor( this.r, this.g, this.b ); + + }, + + copy: function ( color ) { + + this.r = color.r; + this.g = color.g; + this.b = color.b; + + return this; + + }, + + copyGammaToLinear: function ( color, gammaFactor ) { + + if ( gammaFactor === undefined ) gammaFactor = 2.0; + + this.r = Math.pow( color.r, gammaFactor ); + this.g = Math.pow( color.g, gammaFactor ); + this.b = Math.pow( color.b, gammaFactor ); + + return this; + + }, + + copyLinearToGamma: function ( color, gammaFactor ) { + + if ( gammaFactor === undefined ) gammaFactor = 2.0; + + var safeInverse = ( gammaFactor > 0 ) ? ( 1.0 / gammaFactor ) : 1.0; + + this.r = Math.pow( color.r, safeInverse ); + this.g = Math.pow( color.g, safeInverse ); + this.b = Math.pow( color.b, safeInverse ); + + return this; + + }, + + convertGammaToLinear: function ( gammaFactor ) { + + this.copyGammaToLinear( this, gammaFactor ); + + return this; + + }, + + convertLinearToGamma: function ( gammaFactor ) { + + this.copyLinearToGamma( this, gammaFactor ); + + return this; + + }, + + getHex: function () { + + return ( this.r * 255 ) << 16 ^ ( this.g * 255 ) << 8 ^ ( this.b * 255 ) << 0; + + }, + + getHexString: function () { + + return ( '000000' + this.getHex().toString( 16 ) ).slice( - 6 ); + + }, + + getHSL: function ( target ) { + + // h,s,l ranges are in 0.0 - 1.0 + + if ( target === undefined ) { + + console.warn( 'THREE.Color: .getHSL() target is now required' ); + target = { h: 0, s: 0, l: 0 }; + + } + + var r = this.r, g = this.g, b = this.b; + + var max = Math.max( r, g, b ); + var min = Math.min( r, g, b ); + + var hue, saturation; + var lightness = ( min + max ) / 2.0; + + if ( min === max ) { + + hue = 0; + saturation = 0; + + } else { + + var delta = max - min; + + saturation = lightness <= 0.5 ? delta / ( max + min ) : delta / ( 2 - max - min ); + + switch ( max ) { + + case r: hue = ( g - b ) / delta + ( g < b ? 6 : 0 ); break; + case g: hue = ( b - r ) / delta + 2; break; + case b: hue = ( r - g ) / delta + 4; break; + + } + + hue /= 6; + + } + + target.h = hue; + target.s = saturation; + target.l = lightness; + + return target; + + }, + + getStyle: function () { + + return 'rgb(' + ( ( this.r * 255 ) | 0 ) + ',' + ( ( this.g * 255 ) | 0 ) + ',' + ( ( this.b * 255 ) | 0 ) + ')'; + + }, + + offsetHSL: function () { + + var hsl = {}; + + return function ( h, s, l ) { + + this.getHSL( hsl ); + + hsl.h += h; hsl.s += s; hsl.l += l; + + this.setHSL( hsl.h, hsl.s, hsl.l ); + + return this; + + }; + + }(), + + add: function ( color ) { + + this.r += color.r; + this.g += color.g; + this.b += color.b; + + return this; + + }, + + addColors: function ( color1, color2 ) { + + this.r = color1.r + color2.r; + this.g = color1.g + color2.g; + this.b = color1.b + color2.b; + + return this; + + }, + + addScalar: function ( s ) { + + this.r += s; + this.g += s; + this.b += s; + + return this; + + }, + + sub: function ( color ) { + + this.r = Math.max( 0, this.r - color.r ); + this.g = Math.max( 0, this.g - color.g ); + this.b = Math.max( 0, this.b - color.b ); + + return this; + + }, + + multiply: function ( color ) { + + this.r *= color.r; + this.g *= color.g; + this.b *= color.b; + + return this; + + }, + + multiplyScalar: function ( s ) { + + this.r *= s; + this.g *= s; + this.b *= s; + + return this; + + }, + + lerp: function ( color, alpha ) { + + this.r += ( color.r - this.r ) * alpha; + this.g += ( color.g - this.g ) * alpha; + this.b += ( color.b - this.b ) * alpha; + + return this; + + }, + + equals: function ( c ) { + + return ( c.r === this.r ) && ( c.g === this.g ) && ( c.b === this.b ); + + }, + + fromArray: function ( array, offset ) { + + if ( offset === undefined ) offset = 0; + + this.r = array[ offset ]; + this.g = array[ offset + 1 ]; + this.b = array[ offset + 2 ]; + + return this; + + }, + + toArray: function ( array, offset ) { + + if ( array === undefined ) array = []; + if ( offset === undefined ) offset = 0; + + array[ offset ] = this.r; + array[ offset + 1 ] = this.g; + array[ offset + 2 ] = this.b; + + return array; + + }, + + toJSON: function () { + + return this.getHex(); + + } + + } ); + + /** + * Uniforms library for shared webgl shaders + */ + + var UniformsLib = { + + common: { + + diffuse: { value: new Color( 0xeeeeee ) }, + opacity: { value: 1.0 }, + + map: { value: null }, + uvTransform: { value: new Matrix3() }, + + alphaMap: { value: null }, + + }, + + specularmap: { + + specularMap: { value: null }, + + }, + + envmap: { + + envMap: { value: null }, + flipEnvMap: { value: - 1 }, + reflectivity: { value: 1.0 }, + refractionRatio: { value: 0.98 }, + maxMipLevel: { value: 0 } + + }, + + aomap: { + + aoMap: { value: null }, + aoMapIntensity: { value: 1 } + + }, + + lightmap: { + + lightMap: { value: null }, + lightMapIntensity: { value: 1 } + + }, + + emissivemap: { + + emissiveMap: { value: null } + + }, + + bumpmap: { + + bumpMap: { value: null }, + bumpScale: { value: 1 } + + }, + + normalmap: { + + normalMap: { value: null }, + normalScale: { value: new Vector2( 1, 1 ) } + + }, + + displacementmap: { + + displacementMap: { value: null }, + displacementScale: { value: 1 }, + displacementBias: { value: 0 } + + }, + + roughnessmap: { + + roughnessMap: { value: null } + + }, + + metalnessmap: { + + metalnessMap: { value: null } + + }, + + gradientmap: { + + gradientMap: { value: null } + + }, + + fog: { + + fogDensity: { value: 0.00025 }, + fogNear: { value: 1 }, + fogFar: { value: 2000 }, + fogColor: { value: new Color( 0xffffff ) } + + }, + + lights: { + + ambientLightColor: { value: [] }, + + directionalLights: { value: [], properties: { + direction: {}, + color: {}, + + shadow: {}, + shadowBias: {}, + shadowRadius: {}, + shadowMapSize: {} + } }, + + directionalShadowMap: { value: [] }, + directionalShadowMatrix: { value: [] }, + + spotLights: { value: [], properties: { + color: {}, + position: {}, + direction: {}, + distance: {}, + coneCos: {}, + penumbraCos: {}, + decay: {}, + + shadow: {}, + shadowBias: {}, + shadowRadius: {}, + shadowMapSize: {} + } }, + + spotShadowMap: { value: [] }, + spotShadowMatrix: { value: [] }, + + pointLights: { value: [], properties: { + color: {}, + position: {}, + decay: {}, + distance: {}, + + shadow: {}, + shadowBias: {}, + shadowRadius: {}, + shadowMapSize: {}, + shadowCameraNear: {}, + shadowCameraFar: {} + } }, + + pointShadowMap: { value: [] }, + pointShadowMatrix: { value: [] }, + + hemisphereLights: { value: [], properties: { + direction: {}, + skyColor: {}, + groundColor: {} + } }, + + // TODO (abelnation): RectAreaLight BRDF data needs to be moved from example to main src + rectAreaLights: { value: [], properties: { + color: {}, + position: {}, + width: {}, + height: {} + } } + + }, + + points: { + + diffuse: { value: new Color( 0xeeeeee ) }, + opacity: { value: 1.0 }, + size: { value: 1.0 }, + scale: { value: 1.0 }, + map: { value: null }, + uvTransform: { value: new Matrix3() } + + } + + }; + + /** + * @author alteredq / http://alteredqualia.com/ + * @author mrdoob / http://mrdoob.com/ + * @author mikael emtinger / http://gomo.se/ + */ + + var ShaderLib = { + + basic: { + + uniforms: UniformsUtils.merge( [ + UniformsLib.common, + UniformsLib.specularmap, + UniformsLib.envmap, + UniformsLib.aomap, + UniformsLib.lightmap, + UniformsLib.fog + ] ), + + vertexShader: ShaderChunk.meshbasic_vert, + fragmentShader: ShaderChunk.meshbasic_frag + + }, + + lambert: { + + uniforms: UniformsUtils.merge( [ + UniformsLib.common, + UniformsLib.specularmap, + UniformsLib.envmap, + UniformsLib.aomap, + UniformsLib.lightmap, + UniformsLib.emissivemap, + UniformsLib.fog, + UniformsLib.lights, + { + emissive: { value: new Color( 0x000000 ) } + } + ] ), + + vertexShader: ShaderChunk.meshlambert_vert, + fragmentShader: ShaderChunk.meshlambert_frag + + }, + + phong: { + + uniforms: UniformsUtils.merge( [ + UniformsLib.common, + UniformsLib.specularmap, + UniformsLib.envmap, + UniformsLib.aomap, + UniformsLib.lightmap, + UniformsLib.emissivemap, + UniformsLib.bumpmap, + UniformsLib.normalmap, + UniformsLib.displacementmap, + UniformsLib.gradientmap, + UniformsLib.fog, + UniformsLib.lights, + { + emissive: { value: new Color( 0x000000 ) }, + specular: { value: new Color( 0x111111 ) }, + shininess: { value: 30 } + } + ] ), + + vertexShader: ShaderChunk.meshphong_vert, + fragmentShader: ShaderChunk.meshphong_frag + + }, + + standard: { + + uniforms: UniformsUtils.merge( [ + UniformsLib.common, + UniformsLib.envmap, + UniformsLib.aomap, + UniformsLib.lightmap, + UniformsLib.emissivemap, + UniformsLib.bumpmap, + UniformsLib.normalmap, + UniformsLib.displacementmap, + UniformsLib.roughnessmap, + UniformsLib.metalnessmap, + UniformsLib.fog, + UniformsLib.lights, + { + emissive: { value: new Color( 0x000000 ) }, + roughness: { value: 0.5 }, + metalness: { value: 0.5 }, + envMapIntensity: { value: 1 } // temporary + } + ] ), + + vertexShader: ShaderChunk.meshphysical_vert, + fragmentShader: ShaderChunk.meshphysical_frag + + }, + + points: { + + uniforms: UniformsUtils.merge( [ + UniformsLib.points, + UniformsLib.fog + ] ), + + vertexShader: ShaderChunk.points_vert, + fragmentShader: ShaderChunk.points_frag + + }, + + dashed: { + + uniforms: UniformsUtils.merge( [ + UniformsLib.common, + UniformsLib.fog, + { + scale: { value: 1 }, + dashSize: { value: 1 }, + totalSize: { value: 2 } + } + ] ), + + vertexShader: ShaderChunk.linedashed_vert, + fragmentShader: ShaderChunk.linedashed_frag + + }, + + depth: { + + uniforms: UniformsUtils.merge( [ + UniformsLib.common, + UniformsLib.displacementmap + ] ), + + vertexShader: ShaderChunk.depth_vert, + fragmentShader: ShaderChunk.depth_frag + + }, + + normal: { + + uniforms: UniformsUtils.merge( [ + UniformsLib.common, + UniformsLib.bumpmap, + UniformsLib.normalmap, + UniformsLib.displacementmap, + { + opacity: { value: 1.0 } + } + ] ), + + vertexShader: ShaderChunk.normal_vert, + fragmentShader: ShaderChunk.normal_frag + + }, + + /* ------------------------------------------------------------------------- + // Cube map shader + ------------------------------------------------------------------------- */ + + cube: { + + uniforms: { + tCube: { value: null }, + tFlip: { value: - 1 }, + opacity: { value: 1.0 } + }, + + vertexShader: ShaderChunk.cube_vert, + fragmentShader: ShaderChunk.cube_frag + + }, + + equirect: { + + uniforms: { + tEquirect: { value: null }, + }, + + vertexShader: ShaderChunk.equirect_vert, + fragmentShader: ShaderChunk.equirect_frag + + }, + + distanceRGBA: { + + uniforms: UniformsUtils.merge( [ + UniformsLib.common, + UniformsLib.displacementmap, + { + referencePosition: { value: new Vector3() }, + nearDistance: { value: 1 }, + farDistance: { value: 1000 } + } + ] ), + + vertexShader: ShaderChunk.distanceRGBA_vert, + fragmentShader: ShaderChunk.distanceRGBA_frag + + }, + + shadow: { + + uniforms: UniformsUtils.merge( [ + UniformsLib.lights, + UniformsLib.fog, + { + color: { value: new Color( 0x00000 ) }, + opacity: { value: 1.0 } + }, + ] ), + + vertexShader: ShaderChunk.shadow_vert, + fragmentShader: ShaderChunk.shadow_frag + + } + + }; + + ShaderLib.physical = { + + uniforms: UniformsUtils.merge( [ + ShaderLib.standard.uniforms, + { + clearCoat: { value: 0 }, + clearCoatRoughness: { value: 0 } + } + ] ), + + vertexShader: ShaderChunk.meshphysical_vert, + fragmentShader: ShaderChunk.meshphysical_frag + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLAnimation() { + + var context = null; + var isAnimating = false; + var animationLoop = null; + + function onAnimationFrame( time, frame ) { + + if ( isAnimating === false ) return; + + animationLoop( time, frame ); + + context.requestAnimationFrame( onAnimationFrame ); + + } + + return { + + start: function () { + + if ( isAnimating === true ) return; + if ( animationLoop === null ) return; + + context.requestAnimationFrame( onAnimationFrame ); + + isAnimating = true; + + }, + + stop: function () { + + isAnimating = false; + + }, + + setAnimationLoop: function ( callback ) { + + animationLoop = callback; + + }, + + setContext: function ( value ) { + + context = value; + + } + + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLAttributes( gl ) { + + var buffers = new WeakMap(); + + function createBuffer( attribute, bufferType ) { + + var array = attribute.array; + var usage = attribute.dynamic ? gl.DYNAMIC_DRAW : gl.STATIC_DRAW; + + var buffer = gl.createBuffer(); + + gl.bindBuffer( bufferType, buffer ); + gl.bufferData( bufferType, array, usage ); + + attribute.onUploadCallback(); + + var type = gl.FLOAT; + + if ( array instanceof Float32Array ) { + + type = gl.FLOAT; + + } else if ( array instanceof Float64Array ) { + + console.warn( 'THREE.WebGLAttributes: Unsupported data buffer format: Float64Array.' ); + + } else if ( array instanceof Uint16Array ) { + + type = gl.UNSIGNED_SHORT; + + } else if ( array instanceof Int16Array ) { + + type = gl.SHORT; + + } else if ( array instanceof Uint32Array ) { + + type = gl.UNSIGNED_INT; + + } else if ( array instanceof Int32Array ) { + + type = gl.INT; + + } else if ( array instanceof Int8Array ) { + + type = gl.BYTE; + + } else if ( array instanceof Uint8Array ) { + + type = gl.UNSIGNED_BYTE; + + } + + return { + buffer: buffer, + type: type, + bytesPerElement: array.BYTES_PER_ELEMENT, + version: attribute.version + }; + + } + + function updateBuffer( buffer, attribute, bufferType ) { + + var array = attribute.array; + var updateRange = attribute.updateRange; + + gl.bindBuffer( bufferType, buffer ); + + if ( attribute.dynamic === false ) { + + gl.bufferData( bufferType, array, gl.STATIC_DRAW ); + + } else if ( updateRange.count === - 1 ) { + + // Not using update ranges + + gl.bufferSubData( bufferType, 0, array ); + + } else if ( updateRange.count === 0 ) { + + console.error( 'THREE.WebGLObjects.updateBuffer: dynamic THREE.BufferAttribute marked as needsUpdate but updateRange.count is 0, ensure you are using set methods or updating manually.' ); + + } else { + + gl.bufferSubData( bufferType, updateRange.offset * array.BYTES_PER_ELEMENT, + array.subarray( updateRange.offset, updateRange.offset + updateRange.count ) ); + + updateRange.count = - 1; // reset range + + } + + } + + // + + function get( attribute ) { + + if ( attribute.isInterleavedBufferAttribute ) attribute = attribute.data; + + return buffers.get( attribute ); + + } + + function remove( attribute ) { + + if ( attribute.isInterleavedBufferAttribute ) attribute = attribute.data; + + var data = buffers.get( attribute ); + + if ( data ) { + + gl.deleteBuffer( data.buffer ); + + buffers.delete( attribute ); + + } + + } + + function update( attribute, bufferType ) { + + if ( attribute.isInterleavedBufferAttribute ) attribute = attribute.data; + + var data = buffers.get( attribute ); + + if ( data === undefined ) { + + buffers.set( attribute, createBuffer( attribute, bufferType ) ); + + } else if ( data.version < attribute.version ) { + + updateBuffer( data.buffer, attribute, bufferType ); + + data.version = attribute.version; + + } + + } + + return { + + get: get, + remove: remove, + update: update + + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + * @author WestLangley / http://github.com/WestLangley + * @author bhouston / http://clara.io + */ + + function Euler( x, y, z, order ) { + + this._x = x || 0; + this._y = y || 0; + this._z = z || 0; + this._order = order || Euler.DefaultOrder; + + } + + Euler.RotationOrders = [ 'XYZ', 'YZX', 'ZXY', 'XZY', 'YXZ', 'ZYX' ]; + + Euler.DefaultOrder = 'XYZ'; + + Object.defineProperties( Euler.prototype, { + + x: { + + get: function () { + + return this._x; + + }, + + set: function ( value ) { + + this._x = value; + this.onChangeCallback(); + + } + + }, + + y: { + + get: function () { + + return this._y; + + }, + + set: function ( value ) { + + this._y = value; + this.onChangeCallback(); + + } + + }, + + z: { + + get: function () { + + return this._z; + + }, + + set: function ( value ) { + + this._z = value; + this.onChangeCallback(); + + } + + }, + + order: { + + get: function () { + + return this._order; + + }, + + set: function ( value ) { + + this._order = value; + this.onChangeCallback(); + + } + + } + + } ); + + Object.assign( Euler.prototype, { + + isEuler: true, + + set: function ( x, y, z, order ) { + + this._x = x; + this._y = y; + this._z = z; + this._order = order || this._order; + + this.onChangeCallback(); + + return this; + + }, + + clone: function () { + + return new this.constructor( this._x, this._y, this._z, this._order ); + + }, + + copy: function ( euler ) { + + this._x = euler._x; + this._y = euler._y; + this._z = euler._z; + this._order = euler._order; + + this.onChangeCallback(); + + return this; + + }, + + setFromRotationMatrix: function ( m, order, update ) { + + var clamp = _Math.clamp; + + // assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled) + + var te = m.elements; + var m11 = te[ 0 ], m12 = te[ 4 ], m13 = te[ 8 ]; + var m21 = te[ 1 ], m22 = te[ 5 ], m23 = te[ 9 ]; + var m31 = te[ 2 ], m32 = te[ 6 ], m33 = te[ 10 ]; + + order = order || this._order; + + if ( order === 'XYZ' ) { + + this._y = Math.asin( clamp( m13, - 1, 1 ) ); + + if ( Math.abs( m13 ) < 0.99999 ) { + + this._x = Math.atan2( - m23, m33 ); + this._z = Math.atan2( - m12, m11 ); + + } else { + + this._x = Math.atan2( m32, m22 ); + this._z = 0; + + } + + } else if ( order === 'YXZ' ) { + + this._x = Math.asin( - clamp( m23, - 1, 1 ) ); + + if ( Math.abs( m23 ) < 0.99999 ) { + + this._y = Math.atan2( m13, m33 ); + this._z = Math.atan2( m21, m22 ); + + } else { + + this._y = Math.atan2( - m31, m11 ); + this._z = 0; + + } + + } else if ( order === 'ZXY' ) { + + this._x = Math.asin( clamp( m32, - 1, 1 ) ); + + if ( Math.abs( m32 ) < 0.99999 ) { + + this._y = Math.atan2( - m31, m33 ); + this._z = Math.atan2( - m12, m22 ); + + } else { + + this._y = 0; + this._z = Math.atan2( m21, m11 ); + + } + + } else if ( order === 'ZYX' ) { + + this._y = Math.asin( - clamp( m31, - 1, 1 ) ); + + if ( Math.abs( m31 ) < 0.99999 ) { + + this._x = Math.atan2( m32, m33 ); + this._z = Math.atan2( m21, m11 ); + + } else { + + this._x = 0; + this._z = Math.atan2( - m12, m22 ); + + } + + } else if ( order === 'YZX' ) { + + this._z = Math.asin( clamp( m21, - 1, 1 ) ); + + if ( Math.abs( m21 ) < 0.99999 ) { + + this._x = Math.atan2( - m23, m22 ); + this._y = Math.atan2( - m31, m11 ); + + } else { + + this._x = 0; + this._y = Math.atan2( m13, m33 ); + + } + + } else if ( order === 'XZY' ) { + + this._z = Math.asin( - clamp( m12, - 1, 1 ) ); + + if ( Math.abs( m12 ) < 0.99999 ) { + + this._x = Math.atan2( m32, m22 ); + this._y = Math.atan2( m13, m11 ); + + } else { + + this._x = Math.atan2( - m23, m33 ); + this._y = 0; + + } + + } else { + + console.warn( 'THREE.Euler: .setFromRotationMatrix() given unsupported order: ' + order ); + + } + + this._order = order; + + if ( update !== false ) this.onChangeCallback(); + + return this; + + }, + + setFromQuaternion: function () { + + var matrix = new Matrix4(); + + return function setFromQuaternion( q, order, update ) { + + matrix.makeRotationFromQuaternion( q ); + + return this.setFromRotationMatrix( matrix, order, update ); + + }; + + }(), + + setFromVector3: function ( v, order ) { + + return this.set( v.x, v.y, v.z, order || this._order ); + + }, + + reorder: function () { + + // WARNING: this discards revolution information -bhouston + + var q = new Quaternion(); + + return function reorder( newOrder ) { + + q.setFromEuler( this ); + + return this.setFromQuaternion( q, newOrder ); + + }; + + }(), + + equals: function ( euler ) { + + return ( euler._x === this._x ) && ( euler._y === this._y ) && ( euler._z === this._z ) && ( euler._order === this._order ); + + }, + + fromArray: function ( array ) { + + this._x = array[ 0 ]; + this._y = array[ 1 ]; + this._z = array[ 2 ]; + if ( array[ 3 ] !== undefined ) this._order = array[ 3 ]; + + this.onChangeCallback(); + + return this; + + }, + + toArray: function ( array, offset ) { + + if ( array === undefined ) array = []; + if ( offset === undefined ) offset = 0; + + array[ offset ] = this._x; + array[ offset + 1 ] = this._y; + array[ offset + 2 ] = this._z; + array[ offset + 3 ] = this._order; + + return array; + + }, + + toVector3: function ( optionalResult ) { + + if ( optionalResult ) { + + return optionalResult.set( this._x, this._y, this._z ); + + } else { + + return new Vector3( this._x, this._y, this._z ); + + } + + }, + + onChange: function ( callback ) { + + this.onChangeCallback = callback; + + return this; + + }, + + onChangeCallback: function () {} + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function Layers() { + + this.mask = 1 | 0; + + } + + Object.assign( Layers.prototype, { + + set: function ( channel ) { + + this.mask = 1 << channel | 0; + + }, + + enable: function ( channel ) { + + this.mask |= 1 << channel | 0; + + }, + + toggle: function ( channel ) { + + this.mask ^= 1 << channel | 0; + + }, + + disable: function ( channel ) { + + this.mask &= ~ ( 1 << channel | 0 ); + + }, + + test: function ( layers ) { + + return ( this.mask & layers.mask ) !== 0; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author mikael emtinger / http://gomo.se/ + * @author alteredq / http://alteredqualia.com/ + * @author WestLangley / http://github.com/WestLangley + * @author elephantatwork / www.elephantatwork.ch + */ + + var object3DId = 0; + + function Object3D() { + + Object.defineProperty( this, 'id', { value: object3DId ++ } ); + + this.uuid = _Math.generateUUID(); + + this.name = ''; + this.type = 'Object3D'; + + this.parent = null; + this.children = []; + + this.up = Object3D.DefaultUp.clone(); + + var position = new Vector3(); + var rotation = new Euler(); + var quaternion = new Quaternion(); + var scale = new Vector3( 1, 1, 1 ); + + function onRotationChange() { + + quaternion.setFromEuler( rotation, false ); + + } + + function onQuaternionChange() { + + rotation.setFromQuaternion( quaternion, undefined, false ); + + } + + rotation.onChange( onRotationChange ); + quaternion.onChange( onQuaternionChange ); + + Object.defineProperties( this, { + position: { + enumerable: true, + value: position + }, + rotation: { + enumerable: true, + value: rotation + }, + quaternion: { + enumerable: true, + value: quaternion + }, + scale: { + enumerable: true, + value: scale + }, + modelViewMatrix: { + value: new Matrix4() + }, + normalMatrix: { + value: new Matrix3() + } + } ); + + this.matrix = new Matrix4(); + this.matrixWorld = new Matrix4(); + + this.matrixAutoUpdate = Object3D.DefaultMatrixAutoUpdate; + this.matrixWorldNeedsUpdate = false; + + this.layers = new Layers(); + this.visible = true; + + this.castShadow = false; + this.receiveShadow = false; + + this.frustumCulled = true; + this.renderOrder = 0; + + this.userData = {}; + + } + + Object3D.DefaultUp = new Vector3( 0, 1, 0 ); + Object3D.DefaultMatrixAutoUpdate = true; + + Object3D.prototype = Object.assign( Object.create( EventDispatcher.prototype ), { + + constructor: Object3D, + + isObject3D: true, + + onBeforeRender: function () {}, + onAfterRender: function () {}, + + applyMatrix: function ( matrix ) { + + this.matrix.multiplyMatrices( matrix, this.matrix ); + + this.matrix.decompose( this.position, this.quaternion, this.scale ); + + }, + + applyQuaternion: function ( q ) { + + this.quaternion.premultiply( q ); + + return this; + + }, + + setRotationFromAxisAngle: function ( axis, angle ) { + + // assumes axis is normalized + + this.quaternion.setFromAxisAngle( axis, angle ); + + }, + + setRotationFromEuler: function ( euler ) { + + this.quaternion.setFromEuler( euler, true ); + + }, + + setRotationFromMatrix: function ( m ) { + + // assumes the upper 3x3 of m is a pure rotation matrix (i.e, unscaled) + + this.quaternion.setFromRotationMatrix( m ); + + }, + + setRotationFromQuaternion: function ( q ) { + + // assumes q is normalized + + this.quaternion.copy( q ); + + }, + + rotateOnAxis: function () { + + // rotate object on axis in object space + // axis is assumed to be normalized + + var q1 = new Quaternion(); + + return function rotateOnAxis( axis, angle ) { + + q1.setFromAxisAngle( axis, angle ); + + this.quaternion.multiply( q1 ); + + return this; + + }; + + }(), + + rotateOnWorldAxis: function () { + + // rotate object on axis in world space + // axis is assumed to be normalized + // method assumes no rotated parent + + var q1 = new Quaternion(); + + return function rotateOnWorldAxis( axis, angle ) { + + q1.setFromAxisAngle( axis, angle ); + + this.quaternion.premultiply( q1 ); + + return this; + + }; + + }(), + + rotateX: function () { + + var v1 = new Vector3( 1, 0, 0 ); + + return function rotateX( angle ) { + + return this.rotateOnAxis( v1, angle ); + + }; + + }(), + + rotateY: function () { + + var v1 = new Vector3( 0, 1, 0 ); + + return function rotateY( angle ) { + + return this.rotateOnAxis( v1, angle ); + + }; + + }(), + + rotateZ: function () { + + var v1 = new Vector3( 0, 0, 1 ); + + return function rotateZ( angle ) { + + return this.rotateOnAxis( v1, angle ); + + }; + + }(), + + translateOnAxis: function () { + + // translate object by distance along axis in object space + // axis is assumed to be normalized + + var v1 = new Vector3(); + + return function translateOnAxis( axis, distance ) { + + v1.copy( axis ).applyQuaternion( this.quaternion ); + + this.position.add( v1.multiplyScalar( distance ) ); + + return this; + + }; + + }(), + + translateX: function () { + + var v1 = new Vector3( 1, 0, 0 ); + + return function translateX( distance ) { + + return this.translateOnAxis( v1, distance ); + + }; + + }(), + + translateY: function () { + + var v1 = new Vector3( 0, 1, 0 ); + + return function translateY( distance ) { + + return this.translateOnAxis( v1, distance ); + + }; + + }(), + + translateZ: function () { + + var v1 = new Vector3( 0, 0, 1 ); + + return function translateZ( distance ) { + + return this.translateOnAxis( v1, distance ); + + }; + + }(), + + localToWorld: function ( vector ) { + + return vector.applyMatrix4( this.matrixWorld ); + + }, + + worldToLocal: function () { + + var m1 = new Matrix4(); + + return function worldToLocal( vector ) { + + return vector.applyMatrix4( m1.getInverse( this.matrixWorld ) ); + + }; + + }(), + + lookAt: function () { + + // This method does not support objects with rotated and/or translated parent(s) + + var m1 = new Matrix4(); + var vector = new Vector3(); + + return function lookAt( x, y, z ) { + + if ( x.isVector3 ) { + + vector.copy( x ); + + } else { + + vector.set( x, y, z ); + + } + + if ( this.isCamera ) { + + m1.lookAt( this.position, vector, this.up ); + + } else { + + m1.lookAt( vector, this.position, this.up ); + + } + + this.quaternion.setFromRotationMatrix( m1 ); + + }; + + }(), + + add: function ( object ) { + + if ( arguments.length > 1 ) { + + for ( var i = 0; i < arguments.length; i ++ ) { + + this.add( arguments[ i ] ); + + } + + return this; + + } + + if ( object === this ) { + + console.error( "THREE.Object3D.add: object can't be added as a child of itself.", object ); + return this; + + } + + if ( ( object && object.isObject3D ) ) { + + if ( object.parent !== null ) { + + object.parent.remove( object ); + + } + + object.parent = this; + object.dispatchEvent( { type: 'added' } ); + + this.children.push( object ); + + } else { + + console.error( "THREE.Object3D.add: object not an instance of THREE.Object3D.", object ); + + } + + return this; + + }, + + remove: function ( object ) { + + if ( arguments.length > 1 ) { + + for ( var i = 0; i < arguments.length; i ++ ) { + + this.remove( arguments[ i ] ); + + } + + return this; + + } + + var index = this.children.indexOf( object ); + + if ( index !== - 1 ) { + + object.parent = null; + + object.dispatchEvent( { type: 'removed' } ); + + this.children.splice( index, 1 ); + + } + + return this; + + }, + + getObjectById: function ( id ) { + + return this.getObjectByProperty( 'id', id ); + + }, + + getObjectByName: function ( name ) { + + return this.getObjectByProperty( 'name', name ); + + }, + + getObjectByProperty: function ( name, value ) { + + if ( this[ name ] === value ) return this; + + for ( var i = 0, l = this.children.length; i < l; i ++ ) { + + var child = this.children[ i ]; + var object = child.getObjectByProperty( name, value ); + + if ( object !== undefined ) { + + return object; + + } + + } + + return undefined; + + }, + + getWorldPosition: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Object3D: .getWorldPosition() target is now required' ); + target = new Vector3(); + + } + + this.updateMatrixWorld( true ); + + return target.setFromMatrixPosition( this.matrixWorld ); + + }, + + getWorldQuaternion: function () { + + var position = new Vector3(); + var scale = new Vector3(); + + return function getWorldQuaternion( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Object3D: .getWorldQuaternion() target is now required' ); + target = new Quaternion(); + + } + + this.updateMatrixWorld( true ); + + this.matrixWorld.decompose( position, target, scale ); + + return target; + + }; + + }(), + + getWorldScale: function () { + + var position = new Vector3(); + var quaternion = new Quaternion(); + + return function getWorldScale( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Object3D: .getWorldScale() target is now required' ); + target = new Vector3(); + + } + + this.updateMatrixWorld( true ); + + this.matrixWorld.decompose( position, quaternion, target ); + + return target; + + }; + + }(), + + getWorldDirection: function () { + + var quaternion = new Quaternion(); + + return function getWorldDirection( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Object3D: .getWorldDirection() target is now required' ); + target = new Vector3(); + + } + + this.getWorldQuaternion( quaternion ); + + return target.set( 0, 0, 1 ).applyQuaternion( quaternion ); + + }; + + }(), + + raycast: function () {}, + + traverse: function ( callback ) { + + callback( this ); + + var children = this.children; + + for ( var i = 0, l = children.length; i < l; i ++ ) { + + children[ i ].traverse( callback ); + + } + + }, + + traverseVisible: function ( callback ) { + + if ( this.visible === false ) return; + + callback( this ); + + var children = this.children; + + for ( var i = 0, l = children.length; i < l; i ++ ) { + + children[ i ].traverseVisible( callback ); + + } + + }, + + traverseAncestors: function ( callback ) { + + var parent = this.parent; + + if ( parent !== null ) { + + callback( parent ); + + parent.traverseAncestors( callback ); + + } + + }, + + updateMatrix: function () { + + this.matrix.compose( this.position, this.quaternion, this.scale ); + + this.matrixWorldNeedsUpdate = true; + + }, + + updateMatrixWorld: function ( force ) { + + if ( this.matrixAutoUpdate ) this.updateMatrix(); + + if ( this.matrixWorldNeedsUpdate || force ) { + + if ( this.parent === null ) { + + this.matrixWorld.copy( this.matrix ); + + } else { + + this.matrixWorld.multiplyMatrices( this.parent.matrixWorld, this.matrix ); + + } + + this.matrixWorldNeedsUpdate = false; + + force = true; + + } + + // update children + + var children = this.children; + + for ( var i = 0, l = children.length; i < l; i ++ ) { + + children[ i ].updateMatrixWorld( force ); + + } + + }, + + toJSON: function ( meta ) { + + // meta is a string when called from JSON.stringify + var isRootObject = ( meta === undefined || typeof meta === 'string' ); + + var output = {}; + + // meta is a hash used to collect geometries, materials. + // not providing it implies that this is the root object + // being serialized. + if ( isRootObject ) { + + // initialize meta obj + meta = { + geometries: {}, + materials: {}, + textures: {}, + images: {}, + shapes: {} + }; + + output.metadata = { + version: 4.5, + type: 'Object', + generator: 'Object3D.toJSON' + }; + + } + + // standard Object3D serialization + + var object = {}; + + object.uuid = this.uuid; + object.type = this.type; + + if ( this.name !== '' ) object.name = this.name; + if ( this.castShadow === true ) object.castShadow = true; + if ( this.receiveShadow === true ) object.receiveShadow = true; + if ( this.visible === false ) object.visible = false; + if ( this.frustumCulled === false ) object.frustumCulled = false; + if ( this.renderOrder !== 0 ) object.renderOrder = this.renderOrder; + if ( JSON.stringify( this.userData ) !== '{}' ) object.userData = this.userData; + + object.matrix = this.matrix.toArray(); + + if ( this.matrixAutoUpdate === false ) object.matrixAutoUpdate = false; + + // + + function serialize( library, element ) { + + if ( library[ element.uuid ] === undefined ) { + + library[ element.uuid ] = element.toJSON( meta ); + + } + + return element.uuid; + + } + + if ( this.geometry !== undefined ) { + + object.geometry = serialize( meta.geometries, this.geometry ); + + var parameters = this.geometry.parameters; + + if ( parameters !== undefined && parameters.shapes !== undefined ) { + + var shapes = parameters.shapes; + + if ( Array.isArray( shapes ) ) { + + for ( var i = 0, l = shapes.length; i < l; i ++ ) { + + var shape = shapes[ i ]; + + serialize( meta.shapes, shape ); + + } + + } else { + + serialize( meta.shapes, shapes ); + + } + + } + + } + + if ( this.material !== undefined ) { + + if ( Array.isArray( this.material ) ) { + + var uuids = []; + + for ( var i = 0, l = this.material.length; i < l; i ++ ) { + + uuids.push( serialize( meta.materials, this.material[ i ] ) ); + + } + + object.material = uuids; + + } else { + + object.material = serialize( meta.materials, this.material ); + + } + + } + + // + + if ( this.children.length > 0 ) { + + object.children = []; + + for ( var i = 0; i < this.children.length; i ++ ) { + + object.children.push( this.children[ i ].toJSON( meta ).object ); + + } + + } + + if ( isRootObject ) { + + var geometries = extractFromCache( meta.geometries ); + var materials = extractFromCache( meta.materials ); + var textures = extractFromCache( meta.textures ); + var images = extractFromCache( meta.images ); + var shapes = extractFromCache( meta.shapes ); + + if ( geometries.length > 0 ) output.geometries = geometries; + if ( materials.length > 0 ) output.materials = materials; + if ( textures.length > 0 ) output.textures = textures; + if ( images.length > 0 ) output.images = images; + if ( shapes.length > 0 ) output.shapes = shapes; + + } + + output.object = object; + + return output; + + // extract data from the cache hash + // remove metadata on each item + // and return as array + function extractFromCache( cache ) { + + var values = []; + for ( var key in cache ) { + + var data = cache[ key ]; + delete data.metadata; + values.push( data ); + + } + return values; + + } + + }, + + clone: function ( recursive ) { + + return new this.constructor().copy( this, recursive ); + + }, + + copy: function ( source, recursive ) { + + if ( recursive === undefined ) recursive = true; + + this.name = source.name; + + this.up.copy( source.up ); + + this.position.copy( source.position ); + this.quaternion.copy( source.quaternion ); + this.scale.copy( source.scale ); + + this.matrix.copy( source.matrix ); + this.matrixWorld.copy( source.matrixWorld ); + + this.matrixAutoUpdate = source.matrixAutoUpdate; + this.matrixWorldNeedsUpdate = source.matrixWorldNeedsUpdate; + + this.layers.mask = source.layers.mask; + this.visible = source.visible; + + this.castShadow = source.castShadow; + this.receiveShadow = source.receiveShadow; + + this.frustumCulled = source.frustumCulled; + this.renderOrder = source.renderOrder; + + this.userData = JSON.parse( JSON.stringify( source.userData ) ); + + if ( recursive === true ) { + + for ( var i = 0; i < source.children.length; i ++ ) { + + var child = source.children[ i ]; + this.add( child.clone() ); + + } + + } + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author mikael emtinger / http://gomo.se/ + * @author WestLangley / http://github.com/WestLangley + */ + + function Camera() { + + Object3D.call( this ); + + this.type = 'Camera'; + + this.matrixWorldInverse = new Matrix4(); + this.projectionMatrix = new Matrix4(); + + } + + Camera.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: Camera, + + isCamera: true, + + copy: function ( source, recursive ) { + + Object3D.prototype.copy.call( this, source, recursive ); + + this.matrixWorldInverse.copy( source.matrixWorldInverse ); + this.projectionMatrix.copy( source.projectionMatrix ); + + return this; + + }, + + getWorldDirection: function () { + + var quaternion = new Quaternion(); + + return function getWorldDirection( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Camera: .getWorldDirection() target is now required' ); + target = new Vector3(); + + } + + this.getWorldQuaternion( quaternion ); + + return target.set( 0, 0, - 1 ).applyQuaternion( quaternion ); + + }; + + }(), + + updateMatrixWorld: function ( force ) { + + Object3D.prototype.updateMatrixWorld.call( this, force ); + + this.matrixWorldInverse.getInverse( this.matrixWorld ); + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + } + + } ); + + /** + * @author alteredq / http://alteredqualia.com/ + * @author arose / http://github.com/arose + */ + + function OrthographicCamera( left, right, top, bottom, near, far ) { + + Camera.call( this ); + + this.type = 'OrthographicCamera'; + + this.zoom = 1; + this.view = null; + + this.left = left; + this.right = right; + this.top = top; + this.bottom = bottom; + + this.near = ( near !== undefined ) ? near : 0.1; + this.far = ( far !== undefined ) ? far : 2000; + + this.updateProjectionMatrix(); + + } + + OrthographicCamera.prototype = Object.assign( Object.create( Camera.prototype ), { + + constructor: OrthographicCamera, + + isOrthographicCamera: true, + + copy: function ( source, recursive ) { + + Camera.prototype.copy.call( this, source, recursive ); + + this.left = source.left; + this.right = source.right; + this.top = source.top; + this.bottom = source.bottom; + this.near = source.near; + this.far = source.far; + + this.zoom = source.zoom; + this.view = source.view === null ? null : Object.assign( {}, source.view ); + + return this; + + }, + + setViewOffset: function ( fullWidth, fullHeight, x, y, width, height ) { + + if ( this.view === null ) { + + this.view = { + enabled: true, + fullWidth: 1, + fullHeight: 1, + offsetX: 0, + offsetY: 0, + width: 1, + height: 1 + }; + + } + + this.view.enabled = true; + this.view.fullWidth = fullWidth; + this.view.fullHeight = fullHeight; + this.view.offsetX = x; + this.view.offsetY = y; + this.view.width = width; + this.view.height = height; + + this.updateProjectionMatrix(); + + }, + + clearViewOffset: function () { + + if ( this.view !== null ) { + + this.view.enabled = false; + + } + + this.updateProjectionMatrix(); + + }, + + updateProjectionMatrix: function () { + + var dx = ( this.right - this.left ) / ( 2 * this.zoom ); + var dy = ( this.top - this.bottom ) / ( 2 * this.zoom ); + var cx = ( this.right + this.left ) / 2; + var cy = ( this.top + this.bottom ) / 2; + + var left = cx - dx; + var right = cx + dx; + var top = cy + dy; + var bottom = cy - dy; + + if ( this.view !== null && this.view.enabled ) { + + var zoomW = this.zoom / ( this.view.width / this.view.fullWidth ); + var zoomH = this.zoom / ( this.view.height / this.view.fullHeight ); + var scaleW = ( this.right - this.left ) / this.view.width; + var scaleH = ( this.top - this.bottom ) / this.view.height; + + left += scaleW * ( this.view.offsetX / zoomW ); + right = left + scaleW * ( this.view.width / zoomW ); + top -= scaleH * ( this.view.offsetY / zoomH ); + bottom = top - scaleH * ( this.view.height / zoomH ); + + } + + this.projectionMatrix.makeOrthographic( left, right, top, bottom, this.near, this.far ); + + }, + + toJSON: function ( meta ) { + + var data = Object3D.prototype.toJSON.call( this, meta ); + + data.object.zoom = this.zoom; + data.object.left = this.left; + data.object.right = this.right; + data.object.top = this.top; + data.object.bottom = this.bottom; + data.object.near = this.near; + data.object.far = this.far; + + if ( this.view !== null ) data.object.view = Object.assign( {}, this.view ); + + return data; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + */ + + function Face3( a, b, c, normal, color, materialIndex ) { + + this.a = a; + this.b = b; + this.c = c; + + this.normal = ( normal && normal.isVector3 ) ? normal : new Vector3(); + this.vertexNormals = Array.isArray( normal ) ? normal : []; + + this.color = ( color && color.isColor ) ? color : new Color(); + this.vertexColors = Array.isArray( color ) ? color : []; + + this.materialIndex = materialIndex !== undefined ? materialIndex : 0; + + } + + Object.assign( Face3.prototype, { + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( source ) { + + this.a = source.a; + this.b = source.b; + this.c = source.c; + + this.normal.copy( source.normal ); + this.color.copy( source.color ); + + this.materialIndex = source.materialIndex; + + for ( var i = 0, il = source.vertexNormals.length; i < il; i ++ ) { + + this.vertexNormals[ i ] = source.vertexNormals[ i ].clone(); + + } + + for ( var i = 0, il = source.vertexColors.length; i < il; i ++ ) { + + this.vertexColors[ i ] = source.vertexColors[ i ].clone(); + + } + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author kile / http://kile.stravaganza.org/ + * @author alteredq / http://alteredqualia.com/ + * @author mikael emtinger / http://gomo.se/ + * @author zz85 / http://www.lab4games.net/zz85/blog + * @author bhouston / http://clara.io + */ + + var geometryId = 0; // Geometry uses even numbers as Id + + function Geometry() { + + Object.defineProperty( this, 'id', { value: geometryId += 2 } ); + + this.uuid = _Math.generateUUID(); + + this.name = ''; + this.type = 'Geometry'; + + this.vertices = []; + this.colors = []; + this.faces = []; + this.faceVertexUvs = [[]]; + + this.morphTargets = []; + this.morphNormals = []; + + this.skinWeights = []; + this.skinIndices = []; + + this.lineDistances = []; + + this.boundingBox = null; + this.boundingSphere = null; + + // update flags + + this.elementsNeedUpdate = false; + this.verticesNeedUpdate = false; + this.uvsNeedUpdate = false; + this.normalsNeedUpdate = false; + this.colorsNeedUpdate = false; + this.lineDistancesNeedUpdate = false; + this.groupsNeedUpdate = false; + + } + + Geometry.prototype = Object.assign( Object.create( EventDispatcher.prototype ), { + + constructor: Geometry, + + isGeometry: true, + + applyMatrix: function ( matrix ) { + + var normalMatrix = new Matrix3().getNormalMatrix( matrix ); + + for ( var i = 0, il = this.vertices.length; i < il; i ++ ) { + + var vertex = this.vertices[ i ]; + vertex.applyMatrix4( matrix ); + + } + + for ( var i = 0, il = this.faces.length; i < il; i ++ ) { + + var face = this.faces[ i ]; + face.normal.applyMatrix3( normalMatrix ).normalize(); + + for ( var j = 0, jl = face.vertexNormals.length; j < jl; j ++ ) { + + face.vertexNormals[ j ].applyMatrix3( normalMatrix ).normalize(); + + } + + } + + if ( this.boundingBox !== null ) { + + this.computeBoundingBox(); + + } + + if ( this.boundingSphere !== null ) { + + this.computeBoundingSphere(); + + } + + this.verticesNeedUpdate = true; + this.normalsNeedUpdate = true; + + return this; + + }, + + rotateX: function () { + + // rotate geometry around world x-axis + + var m1 = new Matrix4(); + + return function rotateX( angle ) { + + m1.makeRotationX( angle ); + + this.applyMatrix( m1 ); + + return this; + + }; + + }(), + + rotateY: function () { + + // rotate geometry around world y-axis + + var m1 = new Matrix4(); + + return function rotateY( angle ) { + + m1.makeRotationY( angle ); + + this.applyMatrix( m1 ); + + return this; + + }; + + }(), + + rotateZ: function () { + + // rotate geometry around world z-axis + + var m1 = new Matrix4(); + + return function rotateZ( angle ) { + + m1.makeRotationZ( angle ); + + this.applyMatrix( m1 ); + + return this; + + }; + + }(), + + translate: function () { + + // translate geometry + + var m1 = new Matrix4(); + + return function translate( x, y, z ) { + + m1.makeTranslation( x, y, z ); + + this.applyMatrix( m1 ); + + return this; + + }; + + }(), + + scale: function () { + + // scale geometry + + var m1 = new Matrix4(); + + return function scale( x, y, z ) { + + m1.makeScale( x, y, z ); + + this.applyMatrix( m1 ); + + return this; + + }; + + }(), + + lookAt: function () { + + var obj = new Object3D(); + + return function lookAt( vector ) { + + obj.lookAt( vector ); + + obj.updateMatrix(); + + this.applyMatrix( obj.matrix ); + + }; + + }(), + + fromBufferGeometry: function ( geometry ) { + + var scope = this; + + var indices = geometry.index !== null ? geometry.index.array : undefined; + var attributes = geometry.attributes; + + var positions = attributes.position.array; + var normals = attributes.normal !== undefined ? attributes.normal.array : undefined; + var colors = attributes.color !== undefined ? attributes.color.array : undefined; + var uvs = attributes.uv !== undefined ? attributes.uv.array : undefined; + var uvs2 = attributes.uv2 !== undefined ? attributes.uv2.array : undefined; + + if ( uvs2 !== undefined ) this.faceVertexUvs[ 1 ] = []; + + var tempNormals = []; + var tempUVs = []; + var tempUVs2 = []; + + for ( var i = 0, j = 0; i < positions.length; i += 3, j += 2 ) { + + scope.vertices.push( new Vector3( positions[ i ], positions[ i + 1 ], positions[ i + 2 ] ) ); + + if ( normals !== undefined ) { + + tempNormals.push( new Vector3( normals[ i ], normals[ i + 1 ], normals[ i + 2 ] ) ); + + } + + if ( colors !== undefined ) { + + scope.colors.push( new Color( colors[ i ], colors[ i + 1 ], colors[ i + 2 ] ) ); + + } + + if ( uvs !== undefined ) { + + tempUVs.push( new Vector2( uvs[ j ], uvs[ j + 1 ] ) ); + + } + + if ( uvs2 !== undefined ) { + + tempUVs2.push( new Vector2( uvs2[ j ], uvs2[ j + 1 ] ) ); + + } + + } + + function addFace( a, b, c, materialIndex ) { + + var vertexNormals = normals !== undefined ? [ tempNormals[ a ].clone(), tempNormals[ b ].clone(), tempNormals[ c ].clone() ] : []; + var vertexColors = colors !== undefined ? [ scope.colors[ a ].clone(), scope.colors[ b ].clone(), scope.colors[ c ].clone() ] : []; + + var face = new Face3( a, b, c, vertexNormals, vertexColors, materialIndex ); + + scope.faces.push( face ); + + if ( uvs !== undefined ) { + + scope.faceVertexUvs[ 0 ].push( [ tempUVs[ a ].clone(), tempUVs[ b ].clone(), tempUVs[ c ].clone() ] ); + + } + + if ( uvs2 !== undefined ) { + + scope.faceVertexUvs[ 1 ].push( [ tempUVs2[ a ].clone(), tempUVs2[ b ].clone(), tempUVs2[ c ].clone() ] ); + + } + + } + + var groups = geometry.groups; + + if ( groups.length > 0 ) { + + for ( var i = 0; i < groups.length; i ++ ) { + + var group = groups[ i ]; + + var start = group.start; + var count = group.count; + + for ( var j = start, jl = start + count; j < jl; j += 3 ) { + + if ( indices !== undefined ) { + + addFace( indices[ j ], indices[ j + 1 ], indices[ j + 2 ], group.materialIndex ); + + } else { + + addFace( j, j + 1, j + 2, group.materialIndex ); + + } + + } + + } + + } else { + + if ( indices !== undefined ) { + + for ( var i = 0; i < indices.length; i += 3 ) { + + addFace( indices[ i ], indices[ i + 1 ], indices[ i + 2 ] ); + + } + + } else { + + for ( var i = 0; i < positions.length / 3; i += 3 ) { + + addFace( i, i + 1, i + 2 ); + + } + + } + + } + + this.computeFaceNormals(); + + if ( geometry.boundingBox !== null ) { + + this.boundingBox = geometry.boundingBox.clone(); + + } + + if ( geometry.boundingSphere !== null ) { + + this.boundingSphere = geometry.boundingSphere.clone(); + + } + + return this; + + }, + + center: function () { + + var offset = new Vector3(); + + return function center() { + + this.computeBoundingBox(); + + this.boundingBox.getCenter( offset ).negate(); + + this.translate( offset.x, offset.y, offset.z ); + + return this; + + }; + + }(), + + normalize: function () { + + this.computeBoundingSphere(); + + var center = this.boundingSphere.center; + var radius = this.boundingSphere.radius; + + var s = radius === 0 ? 1 : 1.0 / radius; + + var matrix = new Matrix4(); + matrix.set( + s, 0, 0, - s * center.x, + 0, s, 0, - s * center.y, + 0, 0, s, - s * center.z, + 0, 0, 0, 1 + ); + + this.applyMatrix( matrix ); + + return this; + + }, + + computeFaceNormals: function () { + + var cb = new Vector3(), ab = new Vector3(); + + for ( var f = 0, fl = this.faces.length; f < fl; f ++ ) { + + var face = this.faces[ f ]; + + var vA = this.vertices[ face.a ]; + var vB = this.vertices[ face.b ]; + var vC = this.vertices[ face.c ]; + + cb.subVectors( vC, vB ); + ab.subVectors( vA, vB ); + cb.cross( ab ); + + cb.normalize(); + + face.normal.copy( cb ); + + } + + }, + + computeVertexNormals: function ( areaWeighted ) { + + if ( areaWeighted === undefined ) areaWeighted = true; + + var v, vl, f, fl, face, vertices; + + vertices = new Array( this.vertices.length ); + + for ( v = 0, vl = this.vertices.length; v < vl; v ++ ) { + + vertices[ v ] = new Vector3(); + + } + + if ( areaWeighted ) { + + // vertex normals weighted by triangle areas + // http://www.iquilezles.org/www/articles/normals/normals.htm + + var vA, vB, vC; + var cb = new Vector3(), ab = new Vector3(); + + for ( f = 0, fl = this.faces.length; f < fl; f ++ ) { + + face = this.faces[ f ]; + + vA = this.vertices[ face.a ]; + vB = this.vertices[ face.b ]; + vC = this.vertices[ face.c ]; + + cb.subVectors( vC, vB ); + ab.subVectors( vA, vB ); + cb.cross( ab ); + + vertices[ face.a ].add( cb ); + vertices[ face.b ].add( cb ); + vertices[ face.c ].add( cb ); + + } + + } else { + + this.computeFaceNormals(); + + for ( f = 0, fl = this.faces.length; f < fl; f ++ ) { + + face = this.faces[ f ]; + + vertices[ face.a ].add( face.normal ); + vertices[ face.b ].add( face.normal ); + vertices[ face.c ].add( face.normal ); + + } + + } + + for ( v = 0, vl = this.vertices.length; v < vl; v ++ ) { + + vertices[ v ].normalize(); + + } + + for ( f = 0, fl = this.faces.length; f < fl; f ++ ) { + + face = this.faces[ f ]; + + var vertexNormals = face.vertexNormals; + + if ( vertexNormals.length === 3 ) { + + vertexNormals[ 0 ].copy( vertices[ face.a ] ); + vertexNormals[ 1 ].copy( vertices[ face.b ] ); + vertexNormals[ 2 ].copy( vertices[ face.c ] ); + + } else { + + vertexNormals[ 0 ] = vertices[ face.a ].clone(); + vertexNormals[ 1 ] = vertices[ face.b ].clone(); + vertexNormals[ 2 ] = vertices[ face.c ].clone(); + + } + + } + + if ( this.faces.length > 0 ) { + + this.normalsNeedUpdate = true; + + } + + }, + + computeFlatVertexNormals: function () { + + var f, fl, face; + + this.computeFaceNormals(); + + for ( f = 0, fl = this.faces.length; f < fl; f ++ ) { + + face = this.faces[ f ]; + + var vertexNormals = face.vertexNormals; + + if ( vertexNormals.length === 3 ) { + + vertexNormals[ 0 ].copy( face.normal ); + vertexNormals[ 1 ].copy( face.normal ); + vertexNormals[ 2 ].copy( face.normal ); + + } else { + + vertexNormals[ 0 ] = face.normal.clone(); + vertexNormals[ 1 ] = face.normal.clone(); + vertexNormals[ 2 ] = face.normal.clone(); + + } + + } + + if ( this.faces.length > 0 ) { + + this.normalsNeedUpdate = true; + + } + + }, + + computeMorphNormals: function () { + + var i, il, f, fl, face; + + // save original normals + // - create temp variables on first access + // otherwise just copy (for faster repeated calls) + + for ( f = 0, fl = this.faces.length; f < fl; f ++ ) { + + face = this.faces[ f ]; + + if ( ! face.__originalFaceNormal ) { + + face.__originalFaceNormal = face.normal.clone(); + + } else { + + face.__originalFaceNormal.copy( face.normal ); + + } + + if ( ! face.__originalVertexNormals ) face.__originalVertexNormals = []; + + for ( i = 0, il = face.vertexNormals.length; i < il; i ++ ) { + + if ( ! face.__originalVertexNormals[ i ] ) { + + face.__originalVertexNormals[ i ] = face.vertexNormals[ i ].clone(); + + } else { + + face.__originalVertexNormals[ i ].copy( face.vertexNormals[ i ] ); + + } + + } + + } + + // use temp geometry to compute face and vertex normals for each morph + + var tmpGeo = new Geometry(); + tmpGeo.faces = this.faces; + + for ( i = 0, il = this.morphTargets.length; i < il; i ++ ) { + + // create on first access + + if ( ! this.morphNormals[ i ] ) { + + this.morphNormals[ i ] = {}; + this.morphNormals[ i ].faceNormals = []; + this.morphNormals[ i ].vertexNormals = []; + + var dstNormalsFace = this.morphNormals[ i ].faceNormals; + var dstNormalsVertex = this.morphNormals[ i ].vertexNormals; + + var faceNormal, vertexNormals; + + for ( f = 0, fl = this.faces.length; f < fl; f ++ ) { + + faceNormal = new Vector3(); + vertexNormals = { a: new Vector3(), b: new Vector3(), c: new Vector3() }; + + dstNormalsFace.push( faceNormal ); + dstNormalsVertex.push( vertexNormals ); + + } + + } + + var morphNormals = this.morphNormals[ i ]; + + // set vertices to morph target + + tmpGeo.vertices = this.morphTargets[ i ].vertices; + + // compute morph normals + + tmpGeo.computeFaceNormals(); + tmpGeo.computeVertexNormals(); + + // store morph normals + + var faceNormal, vertexNormals; + + for ( f = 0, fl = this.faces.length; f < fl; f ++ ) { + + face = this.faces[ f ]; + + faceNormal = morphNormals.faceNormals[ f ]; + vertexNormals = morphNormals.vertexNormals[ f ]; + + faceNormal.copy( face.normal ); + + vertexNormals.a.copy( face.vertexNormals[ 0 ] ); + vertexNormals.b.copy( face.vertexNormals[ 1 ] ); + vertexNormals.c.copy( face.vertexNormals[ 2 ] ); + + } + + } + + // restore original normals + + for ( f = 0, fl = this.faces.length; f < fl; f ++ ) { + + face = this.faces[ f ]; + + face.normal = face.__originalFaceNormal; + face.vertexNormals = face.__originalVertexNormals; + + } + + }, + + computeBoundingBox: function () { + + if ( this.boundingBox === null ) { + + this.boundingBox = new Box3(); + + } + + this.boundingBox.setFromPoints( this.vertices ); + + }, + + computeBoundingSphere: function () { + + if ( this.boundingSphere === null ) { + + this.boundingSphere = new Sphere(); + + } + + this.boundingSphere.setFromPoints( this.vertices ); + + }, + + merge: function ( geometry, matrix, materialIndexOffset ) { + + if ( ! ( geometry && geometry.isGeometry ) ) { + + console.error( 'THREE.Geometry.merge(): geometry not an instance of THREE.Geometry.', geometry ); + return; + + } + + var normalMatrix, + vertexOffset = this.vertices.length, + vertices1 = this.vertices, + vertices2 = geometry.vertices, + faces1 = this.faces, + faces2 = geometry.faces, + uvs1 = this.faceVertexUvs[ 0 ], + uvs2 = geometry.faceVertexUvs[ 0 ], + colors1 = this.colors, + colors2 = geometry.colors; + + if ( materialIndexOffset === undefined ) materialIndexOffset = 0; + + if ( matrix !== undefined ) { + + normalMatrix = new Matrix3().getNormalMatrix( matrix ); + + } + + // vertices + + for ( var i = 0, il = vertices2.length; i < il; i ++ ) { + + var vertex = vertices2[ i ]; + + var vertexCopy = vertex.clone(); + + if ( matrix !== undefined ) vertexCopy.applyMatrix4( matrix ); + + vertices1.push( vertexCopy ); + + } + + // colors + + for ( var i = 0, il = colors2.length; i < il; i ++ ) { + + colors1.push( colors2[ i ].clone() ); + + } + + // faces + + for ( i = 0, il = faces2.length; i < il; i ++ ) { + + var face = faces2[ i ], faceCopy, normal, color, + faceVertexNormals = face.vertexNormals, + faceVertexColors = face.vertexColors; + + faceCopy = new Face3( face.a + vertexOffset, face.b + vertexOffset, face.c + vertexOffset ); + faceCopy.normal.copy( face.normal ); + + if ( normalMatrix !== undefined ) { + + faceCopy.normal.applyMatrix3( normalMatrix ).normalize(); + + } + + for ( var j = 0, jl = faceVertexNormals.length; j < jl; j ++ ) { + + normal = faceVertexNormals[ j ].clone(); + + if ( normalMatrix !== undefined ) { + + normal.applyMatrix3( normalMatrix ).normalize(); + + } + + faceCopy.vertexNormals.push( normal ); + + } + + faceCopy.color.copy( face.color ); + + for ( var j = 0, jl = faceVertexColors.length; j < jl; j ++ ) { + + color = faceVertexColors[ j ]; + faceCopy.vertexColors.push( color.clone() ); + + } + + faceCopy.materialIndex = face.materialIndex + materialIndexOffset; + + faces1.push( faceCopy ); + + } + + // uvs + + for ( i = 0, il = uvs2.length; i < il; i ++ ) { + + var uv = uvs2[ i ], uvCopy = []; + + if ( uv === undefined ) { + + continue; + + } + + for ( var j = 0, jl = uv.length; j < jl; j ++ ) { + + uvCopy.push( uv[ j ].clone() ); + + } + + uvs1.push( uvCopy ); + + } + + }, + + mergeMesh: function ( mesh ) { + + if ( ! ( mesh && mesh.isMesh ) ) { + + console.error( 'THREE.Geometry.mergeMesh(): mesh not an instance of THREE.Mesh.', mesh ); + return; + + } + + if ( mesh.matrixAutoUpdate ) mesh.updateMatrix(); + + this.merge( mesh.geometry, mesh.matrix ); + + }, + + /* + * Checks for duplicate vertices with hashmap. + * Duplicated vertices are removed + * and faces' vertices are updated. + */ + + mergeVertices: function () { + + var verticesMap = {}; // Hashmap for looking up vertices by position coordinates (and making sure they are unique) + var unique = [], changes = []; + + var v, key; + var precisionPoints = 4; // number of decimal points, e.g. 4 for epsilon of 0.0001 + var precision = Math.pow( 10, precisionPoints ); + var i, il, face; + var indices, j, jl; + + for ( i = 0, il = this.vertices.length; i < il; i ++ ) { + + v = this.vertices[ i ]; + key = Math.round( v.x * precision ) + '_' + Math.round( v.y * precision ) + '_' + Math.round( v.z * precision ); + + if ( verticesMap[ key ] === undefined ) { + + verticesMap[ key ] = i; + unique.push( this.vertices[ i ] ); + changes[ i ] = unique.length - 1; + + } else { + + //console.log('Duplicate vertex found. ', i, ' could be using ', verticesMap[key]); + changes[ i ] = changes[ verticesMap[ key ] ]; + + } + + } + + + // if faces are completely degenerate after merging vertices, we + // have to remove them from the geometry. + var faceIndicesToRemove = []; + + for ( i = 0, il = this.faces.length; i < il; i ++ ) { + + face = this.faces[ i ]; + + face.a = changes[ face.a ]; + face.b = changes[ face.b ]; + face.c = changes[ face.c ]; + + indices = [ face.a, face.b, face.c ]; + + // if any duplicate vertices are found in a Face3 + // we have to remove the face as nothing can be saved + for ( var n = 0; n < 3; n ++ ) { + + if ( indices[ n ] === indices[ ( n + 1 ) % 3 ] ) { + + faceIndicesToRemove.push( i ); + break; + + } + + } + + } + + for ( i = faceIndicesToRemove.length - 1; i >= 0; i -- ) { + + var idx = faceIndicesToRemove[ i ]; + + this.faces.splice( idx, 1 ); + + for ( j = 0, jl = this.faceVertexUvs.length; j < jl; j ++ ) { + + this.faceVertexUvs[ j ].splice( idx, 1 ); + + } + + } + + // Use unique set of vertices + + var diff = this.vertices.length - unique.length; + this.vertices = unique; + return diff; + + }, + + setFromPoints: function ( points ) { + + this.vertices = []; + + for ( var i = 0, l = points.length; i < l; i ++ ) { + + var point = points[ i ]; + this.vertices.push( new Vector3( point.x, point.y, point.z || 0 ) ); + + } + + return this; + + }, + + sortFacesByMaterialIndex: function () { + + var faces = this.faces; + var length = faces.length; + + // tag faces + + for ( var i = 0; i < length; i ++ ) { + + faces[ i ]._id = i; + + } + + // sort faces + + function materialIndexSort( a, b ) { + + return a.materialIndex - b.materialIndex; + + } + + faces.sort( materialIndexSort ); + + // sort uvs + + var uvs1 = this.faceVertexUvs[ 0 ]; + var uvs2 = this.faceVertexUvs[ 1 ]; + + var newUvs1, newUvs2; + + if ( uvs1 && uvs1.length === length ) newUvs1 = []; + if ( uvs2 && uvs2.length === length ) newUvs2 = []; + + for ( var i = 0; i < length; i ++ ) { + + var id = faces[ i ]._id; + + if ( newUvs1 ) newUvs1.push( uvs1[ id ] ); + if ( newUvs2 ) newUvs2.push( uvs2[ id ] ); + + } + + if ( newUvs1 ) this.faceVertexUvs[ 0 ] = newUvs1; + if ( newUvs2 ) this.faceVertexUvs[ 1 ] = newUvs2; + + }, + + toJSON: function () { + + var data = { + metadata: { + version: 4.5, + type: 'Geometry', + generator: 'Geometry.toJSON' + } + }; + + // standard Geometry serialization + + data.uuid = this.uuid; + data.type = this.type; + if ( this.name !== '' ) data.name = this.name; + + if ( this.parameters !== undefined ) { + + var parameters = this.parameters; + + for ( var key in parameters ) { + + if ( parameters[ key ] !== undefined ) data[ key ] = parameters[ key ]; + + } + + return data; + + } + + var vertices = []; + + for ( var i = 0; i < this.vertices.length; i ++ ) { + + var vertex = this.vertices[ i ]; + vertices.push( vertex.x, vertex.y, vertex.z ); + + } + + var faces = []; + var normals = []; + var normalsHash = {}; + var colors = []; + var colorsHash = {}; + var uvs = []; + var uvsHash = {}; + + for ( var i = 0; i < this.faces.length; i ++ ) { + + var face = this.faces[ i ]; + + var hasMaterial = true; + var hasFaceUv = false; // deprecated + var hasFaceVertexUv = this.faceVertexUvs[ 0 ][ i ] !== undefined; + var hasFaceNormal = face.normal.length() > 0; + var hasFaceVertexNormal = face.vertexNormals.length > 0; + var hasFaceColor = face.color.r !== 1 || face.color.g !== 1 || face.color.b !== 1; + var hasFaceVertexColor = face.vertexColors.length > 0; + + var faceType = 0; + + faceType = setBit( faceType, 0, 0 ); // isQuad + faceType = setBit( faceType, 1, hasMaterial ); + faceType = setBit( faceType, 2, hasFaceUv ); + faceType = setBit( faceType, 3, hasFaceVertexUv ); + faceType = setBit( faceType, 4, hasFaceNormal ); + faceType = setBit( faceType, 5, hasFaceVertexNormal ); + faceType = setBit( faceType, 6, hasFaceColor ); + faceType = setBit( faceType, 7, hasFaceVertexColor ); + + faces.push( faceType ); + faces.push( face.a, face.b, face.c ); + faces.push( face.materialIndex ); + + if ( hasFaceVertexUv ) { + + var faceVertexUvs = this.faceVertexUvs[ 0 ][ i ]; + + faces.push( + getUvIndex( faceVertexUvs[ 0 ] ), + getUvIndex( faceVertexUvs[ 1 ] ), + getUvIndex( faceVertexUvs[ 2 ] ) + ); + + } + + if ( hasFaceNormal ) { + + faces.push( getNormalIndex( face.normal ) ); + + } + + if ( hasFaceVertexNormal ) { + + var vertexNormals = face.vertexNormals; + + faces.push( + getNormalIndex( vertexNormals[ 0 ] ), + getNormalIndex( vertexNormals[ 1 ] ), + getNormalIndex( vertexNormals[ 2 ] ) + ); + + } + + if ( hasFaceColor ) { + + faces.push( getColorIndex( face.color ) ); + + } + + if ( hasFaceVertexColor ) { + + var vertexColors = face.vertexColors; + + faces.push( + getColorIndex( vertexColors[ 0 ] ), + getColorIndex( vertexColors[ 1 ] ), + getColorIndex( vertexColors[ 2 ] ) + ); + + } + + } + + function setBit( value, position, enabled ) { + + return enabled ? value | ( 1 << position ) : value & ( ~ ( 1 << position ) ); + + } + + function getNormalIndex( normal ) { + + var hash = normal.x.toString() + normal.y.toString() + normal.z.toString(); + + if ( normalsHash[ hash ] !== undefined ) { + + return normalsHash[ hash ]; + + } + + normalsHash[ hash ] = normals.length / 3; + normals.push( normal.x, normal.y, normal.z ); + + return normalsHash[ hash ]; + + } + + function getColorIndex( color ) { + + var hash = color.r.toString() + color.g.toString() + color.b.toString(); + + if ( colorsHash[ hash ] !== undefined ) { + + return colorsHash[ hash ]; + + } + + colorsHash[ hash ] = colors.length; + colors.push( color.getHex() ); + + return colorsHash[ hash ]; + + } + + function getUvIndex( uv ) { + + var hash = uv.x.toString() + uv.y.toString(); + + if ( uvsHash[ hash ] !== undefined ) { + + return uvsHash[ hash ]; + + } + + uvsHash[ hash ] = uvs.length / 2; + uvs.push( uv.x, uv.y ); + + return uvsHash[ hash ]; + + } + + data.data = {}; + + data.data.vertices = vertices; + data.data.normals = normals; + if ( colors.length > 0 ) data.data.colors = colors; + if ( uvs.length > 0 ) data.data.uvs = [ uvs ]; // temporal backward compatibility + data.data.faces = faces; + + return data; + + }, + + clone: function () { + + /* + // Handle primitives + + var parameters = this.parameters; + + if ( parameters !== undefined ) { + + var values = []; + + for ( var key in parameters ) { + + values.push( parameters[ key ] ); + + } + + var geometry = Object.create( this.constructor.prototype ); + this.constructor.apply( geometry, values ); + return geometry; + + } + + return new this.constructor().copy( this ); + */ + + return new Geometry().copy( this ); + + }, + + copy: function ( source ) { + + var i, il, j, jl, k, kl; + + // reset + + this.vertices = []; + this.colors = []; + this.faces = []; + this.faceVertexUvs = [[]]; + this.morphTargets = []; + this.morphNormals = []; + this.skinWeights = []; + this.skinIndices = []; + this.lineDistances = []; + this.boundingBox = null; + this.boundingSphere = null; + + // name + + this.name = source.name; + + // vertices + + var vertices = source.vertices; + + for ( i = 0, il = vertices.length; i < il; i ++ ) { + + this.vertices.push( vertices[ i ].clone() ); + + } + + // colors + + var colors = source.colors; + + for ( i = 0, il = colors.length; i < il; i ++ ) { + + this.colors.push( colors[ i ].clone() ); + + } + + // faces + + var faces = source.faces; + + for ( i = 0, il = faces.length; i < il; i ++ ) { + + this.faces.push( faces[ i ].clone() ); + + } + + // face vertex uvs + + for ( i = 0, il = source.faceVertexUvs.length; i < il; i ++ ) { + + var faceVertexUvs = source.faceVertexUvs[ i ]; + + if ( this.faceVertexUvs[ i ] === undefined ) { + + this.faceVertexUvs[ i ] = []; + + } + + for ( j = 0, jl = faceVertexUvs.length; j < jl; j ++ ) { + + var uvs = faceVertexUvs[ j ], uvsCopy = []; + + for ( k = 0, kl = uvs.length; k < kl; k ++ ) { + + var uv = uvs[ k ]; + + uvsCopy.push( uv.clone() ); + + } + + this.faceVertexUvs[ i ].push( uvsCopy ); + + } + + } + + // morph targets + + var morphTargets = source.morphTargets; + + for ( i = 0, il = morphTargets.length; i < il; i ++ ) { + + var morphTarget = {}; + morphTarget.name = morphTargets[ i ].name; + + // vertices + + if ( morphTargets[ i ].vertices !== undefined ) { + + morphTarget.vertices = []; + + for ( j = 0, jl = morphTargets[ i ].vertices.length; j < jl; j ++ ) { + + morphTarget.vertices.push( morphTargets[ i ].vertices[ j ].clone() ); + + } + + } + + // normals + + if ( morphTargets[ i ].normals !== undefined ) { + + morphTarget.normals = []; + + for ( j = 0, jl = morphTargets[ i ].normals.length; j < jl; j ++ ) { + + morphTarget.normals.push( morphTargets[ i ].normals[ j ].clone() ); + + } + + } + + this.morphTargets.push( morphTarget ); + + } + + // morph normals + + var morphNormals = source.morphNormals; + + for ( i = 0, il = morphNormals.length; i < il; i ++ ) { + + var morphNormal = {}; + + // vertex normals + + if ( morphNormals[ i ].vertexNormals !== undefined ) { + + morphNormal.vertexNormals = []; + + for ( j = 0, jl = morphNormals[ i ].vertexNormals.length; j < jl; j ++ ) { + + var srcVertexNormal = morphNormals[ i ].vertexNormals[ j ]; + var destVertexNormal = {}; + + destVertexNormal.a = srcVertexNormal.a.clone(); + destVertexNormal.b = srcVertexNormal.b.clone(); + destVertexNormal.c = srcVertexNormal.c.clone(); + + morphNormal.vertexNormals.push( destVertexNormal ); + + } + + } + + // face normals + + if ( morphNormals[ i ].faceNormals !== undefined ) { + + morphNormal.faceNormals = []; + + for ( j = 0, jl = morphNormals[ i ].faceNormals.length; j < jl; j ++ ) { + + morphNormal.faceNormals.push( morphNormals[ i ].faceNormals[ j ].clone() ); + + } + + } + + this.morphNormals.push( morphNormal ); + + } + + // skin weights + + var skinWeights = source.skinWeights; + + for ( i = 0, il = skinWeights.length; i < il; i ++ ) { + + this.skinWeights.push( skinWeights[ i ].clone() ); + + } + + // skin indices + + var skinIndices = source.skinIndices; + + for ( i = 0, il = skinIndices.length; i < il; i ++ ) { + + this.skinIndices.push( skinIndices[ i ].clone() ); + + } + + // line distances + + var lineDistances = source.lineDistances; + + for ( i = 0, il = lineDistances.length; i < il; i ++ ) { + + this.lineDistances.push( lineDistances[ i ] ); + + } + + // bounding box + + var boundingBox = source.boundingBox; + + if ( boundingBox !== null ) { + + this.boundingBox = boundingBox.clone(); + + } + + // bounding sphere + + var boundingSphere = source.boundingSphere; + + if ( boundingSphere !== null ) { + + this.boundingSphere = boundingSphere.clone(); + + } + + // update flags + + this.elementsNeedUpdate = source.elementsNeedUpdate; + this.verticesNeedUpdate = source.verticesNeedUpdate; + this.uvsNeedUpdate = source.uvsNeedUpdate; + this.normalsNeedUpdate = source.normalsNeedUpdate; + this.colorsNeedUpdate = source.colorsNeedUpdate; + this.lineDistancesNeedUpdate = source.lineDistancesNeedUpdate; + this.groupsNeedUpdate = source.groupsNeedUpdate; + + return this; + + }, + + dispose: function () { + + this.dispatchEvent( { type: 'dispose' } ); + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function BufferAttribute( array, itemSize, normalized ) { + + if ( Array.isArray( array ) ) { + + throw new TypeError( 'THREE.BufferAttribute: array should be a Typed Array.' ); + + } + + this.name = ''; + + this.array = array; + this.itemSize = itemSize; + this.count = array !== undefined ? array.length / itemSize : 0; + this.normalized = normalized === true; + + this.dynamic = false; + this.updateRange = { offset: 0, count: - 1 }; + + this.version = 0; + + } + + Object.defineProperty( BufferAttribute.prototype, 'needsUpdate', { + + set: function ( value ) { + + if ( value === true ) this.version ++; + + } + + } ); + + Object.assign( BufferAttribute.prototype, { + + isBufferAttribute: true, + + onUploadCallback: function () {}, + + setArray: function ( array ) { + + if ( Array.isArray( array ) ) { + + throw new TypeError( 'THREE.BufferAttribute: array should be a Typed Array.' ); + + } + + this.count = array !== undefined ? array.length / this.itemSize : 0; + this.array = array; + + return this; + + }, + + setDynamic: function ( value ) { + + this.dynamic = value; + + return this; + + }, + + copy: function ( source ) { + + this.name = source.name; + this.array = new source.array.constructor( source.array ); + this.itemSize = source.itemSize; + this.count = source.count; + this.normalized = source.normalized; + + this.dynamic = source.dynamic; + + return this; + + }, + + copyAt: function ( index1, attribute, index2 ) { + + index1 *= this.itemSize; + index2 *= attribute.itemSize; + + for ( var i = 0, l = this.itemSize; i < l; i ++ ) { + + this.array[ index1 + i ] = attribute.array[ index2 + i ]; + + } + + return this; + + }, + + copyArray: function ( array ) { + + this.array.set( array ); + + return this; + + }, + + copyColorsArray: function ( colors ) { + + var array = this.array, offset = 0; + + for ( var i = 0, l = colors.length; i < l; i ++ ) { + + var color = colors[ i ]; + + if ( color === undefined ) { + + console.warn( 'THREE.BufferAttribute.copyColorsArray(): color is undefined', i ); + color = new Color(); + + } + + array[ offset ++ ] = color.r; + array[ offset ++ ] = color.g; + array[ offset ++ ] = color.b; + + } + + return this; + + }, + + copyVector2sArray: function ( vectors ) { + + var array = this.array, offset = 0; + + for ( var i = 0, l = vectors.length; i < l; i ++ ) { + + var vector = vectors[ i ]; + + if ( vector === undefined ) { + + console.warn( 'THREE.BufferAttribute.copyVector2sArray(): vector is undefined', i ); + vector = new Vector2(); + + } + + array[ offset ++ ] = vector.x; + array[ offset ++ ] = vector.y; + + } + + return this; + + }, + + copyVector3sArray: function ( vectors ) { + + var array = this.array, offset = 0; + + for ( var i = 0, l = vectors.length; i < l; i ++ ) { + + var vector = vectors[ i ]; + + if ( vector === undefined ) { + + console.warn( 'THREE.BufferAttribute.copyVector3sArray(): vector is undefined', i ); + vector = new Vector3(); + + } + + array[ offset ++ ] = vector.x; + array[ offset ++ ] = vector.y; + array[ offset ++ ] = vector.z; + + } + + return this; + + }, + + copyVector4sArray: function ( vectors ) { + + var array = this.array, offset = 0; + + for ( var i = 0, l = vectors.length; i < l; i ++ ) { + + var vector = vectors[ i ]; + + if ( vector === undefined ) { + + console.warn( 'THREE.BufferAttribute.copyVector4sArray(): vector is undefined', i ); + vector = new Vector4(); + + } + + array[ offset ++ ] = vector.x; + array[ offset ++ ] = vector.y; + array[ offset ++ ] = vector.z; + array[ offset ++ ] = vector.w; + + } + + return this; + + }, + + set: function ( value, offset ) { + + if ( offset === undefined ) offset = 0; + + this.array.set( value, offset ); + + return this; + + }, + + getX: function ( index ) { + + return this.array[ index * this.itemSize ]; + + }, + + setX: function ( index, x ) { + + this.array[ index * this.itemSize ] = x; + + return this; + + }, + + getY: function ( index ) { + + return this.array[ index * this.itemSize + 1 ]; + + }, + + setY: function ( index, y ) { + + this.array[ index * this.itemSize + 1 ] = y; + + return this; + + }, + + getZ: function ( index ) { + + return this.array[ index * this.itemSize + 2 ]; + + }, + + setZ: function ( index, z ) { + + this.array[ index * this.itemSize + 2 ] = z; + + return this; + + }, + + getW: function ( index ) { + + return this.array[ index * this.itemSize + 3 ]; + + }, + + setW: function ( index, w ) { + + this.array[ index * this.itemSize + 3 ] = w; + + return this; + + }, + + setXY: function ( index, x, y ) { + + index *= this.itemSize; + + this.array[ index + 0 ] = x; + this.array[ index + 1 ] = y; + + return this; + + }, + + setXYZ: function ( index, x, y, z ) { + + index *= this.itemSize; + + this.array[ index + 0 ] = x; + this.array[ index + 1 ] = y; + this.array[ index + 2 ] = z; + + return this; + + }, + + setXYZW: function ( index, x, y, z, w ) { + + index *= this.itemSize; + + this.array[ index + 0 ] = x; + this.array[ index + 1 ] = y; + this.array[ index + 2 ] = z; + this.array[ index + 3 ] = w; + + return this; + + }, + + onUpload: function ( callback ) { + + this.onUploadCallback = callback; + + return this; + + }, + + clone: function () { + + return new this.constructor( this.array, this.itemSize ).copy( this ); + + } + + } ); + + // + + function Int8BufferAttribute( array, itemSize, normalized ) { + + BufferAttribute.call( this, new Int8Array( array ), itemSize, normalized ); + + } + + Int8BufferAttribute.prototype = Object.create( BufferAttribute.prototype ); + Int8BufferAttribute.prototype.constructor = Int8BufferAttribute; + + + function Uint8BufferAttribute( array, itemSize, normalized ) { + + BufferAttribute.call( this, new Uint8Array( array ), itemSize, normalized ); + + } + + Uint8BufferAttribute.prototype = Object.create( BufferAttribute.prototype ); + Uint8BufferAttribute.prototype.constructor = Uint8BufferAttribute; + + + function Uint8ClampedBufferAttribute( array, itemSize, normalized ) { + + BufferAttribute.call( this, new Uint8ClampedArray( array ), itemSize, normalized ); + + } + + Uint8ClampedBufferAttribute.prototype = Object.create( BufferAttribute.prototype ); + Uint8ClampedBufferAttribute.prototype.constructor = Uint8ClampedBufferAttribute; + + + function Int16BufferAttribute( array, itemSize, normalized ) { + + BufferAttribute.call( this, new Int16Array( array ), itemSize, normalized ); + + } + + Int16BufferAttribute.prototype = Object.create( BufferAttribute.prototype ); + Int16BufferAttribute.prototype.constructor = Int16BufferAttribute; + + + function Uint16BufferAttribute( array, itemSize, normalized ) { + + BufferAttribute.call( this, new Uint16Array( array ), itemSize, normalized ); + + } + + Uint16BufferAttribute.prototype = Object.create( BufferAttribute.prototype ); + Uint16BufferAttribute.prototype.constructor = Uint16BufferAttribute; + + + function Int32BufferAttribute( array, itemSize, normalized ) { + + BufferAttribute.call( this, new Int32Array( array ), itemSize, normalized ); + + } + + Int32BufferAttribute.prototype = Object.create( BufferAttribute.prototype ); + Int32BufferAttribute.prototype.constructor = Int32BufferAttribute; + + + function Uint32BufferAttribute( array, itemSize, normalized ) { + + BufferAttribute.call( this, new Uint32Array( array ), itemSize, normalized ); + + } + + Uint32BufferAttribute.prototype = Object.create( BufferAttribute.prototype ); + Uint32BufferAttribute.prototype.constructor = Uint32BufferAttribute; + + + function Float32BufferAttribute( array, itemSize, normalized ) { + + BufferAttribute.call( this, new Float32Array( array ), itemSize, normalized ); + + } + + Float32BufferAttribute.prototype = Object.create( BufferAttribute.prototype ); + Float32BufferAttribute.prototype.constructor = Float32BufferAttribute; + + + function Float64BufferAttribute( array, itemSize, normalized ) { + + BufferAttribute.call( this, new Float64Array( array ), itemSize, normalized ); + + } + + Float64BufferAttribute.prototype = Object.create( BufferAttribute.prototype ); + Float64BufferAttribute.prototype.constructor = Float64BufferAttribute; + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function DirectGeometry() { + + this.vertices = []; + this.normals = []; + this.colors = []; + this.uvs = []; + this.uvs2 = []; + + this.groups = []; + + this.morphTargets = {}; + + this.skinWeights = []; + this.skinIndices = []; + + // this.lineDistances = []; + + this.boundingBox = null; + this.boundingSphere = null; + + // update flags + + this.verticesNeedUpdate = false; + this.normalsNeedUpdate = false; + this.colorsNeedUpdate = false; + this.uvsNeedUpdate = false; + this.groupsNeedUpdate = false; + + } + + Object.assign( DirectGeometry.prototype, { + + computeGroups: function ( geometry ) { + + var group; + var groups = []; + var materialIndex = undefined; + + var faces = geometry.faces; + + for ( var i = 0; i < faces.length; i ++ ) { + + var face = faces[ i ]; + + // materials + + if ( face.materialIndex !== materialIndex ) { + + materialIndex = face.materialIndex; + + if ( group !== undefined ) { + + group.count = ( i * 3 ) - group.start; + groups.push( group ); + + } + + group = { + start: i * 3, + materialIndex: materialIndex + }; + + } + + } + + if ( group !== undefined ) { + + group.count = ( i * 3 ) - group.start; + groups.push( group ); + + } + + this.groups = groups; + + }, + + fromGeometry: function ( geometry ) { + + var faces = geometry.faces; + var vertices = geometry.vertices; + var faceVertexUvs = geometry.faceVertexUvs; + + var hasFaceVertexUv = faceVertexUvs[ 0 ] && faceVertexUvs[ 0 ].length > 0; + var hasFaceVertexUv2 = faceVertexUvs[ 1 ] && faceVertexUvs[ 1 ].length > 0; + + // morphs + + var morphTargets = geometry.morphTargets; + var morphTargetsLength = morphTargets.length; + + var morphTargetsPosition; + + if ( morphTargetsLength > 0 ) { + + morphTargetsPosition = []; + + for ( var i = 0; i < morphTargetsLength; i ++ ) { + + morphTargetsPosition[ i ] = []; + + } + + this.morphTargets.position = morphTargetsPosition; + + } + + var morphNormals = geometry.morphNormals; + var morphNormalsLength = morphNormals.length; + + var morphTargetsNormal; + + if ( morphNormalsLength > 0 ) { + + morphTargetsNormal = []; + + for ( var i = 0; i < morphNormalsLength; i ++ ) { + + morphTargetsNormal[ i ] = []; + + } + + this.morphTargets.normal = morphTargetsNormal; + + } + + // skins + + var skinIndices = geometry.skinIndices; + var skinWeights = geometry.skinWeights; + + var hasSkinIndices = skinIndices.length === vertices.length; + var hasSkinWeights = skinWeights.length === vertices.length; + + // + + if ( faces.length === 0 ) { + + console.error( 'THREE.DirectGeometry: Faceless geometries are not supported.' ); + + } + + for ( var i = 0; i < faces.length; i ++ ) { + + var face = faces[ i ]; + + this.vertices.push( vertices[ face.a ], vertices[ face.b ], vertices[ face.c ] ); + + var vertexNormals = face.vertexNormals; + + if ( vertexNormals.length === 3 ) { + + this.normals.push( vertexNormals[ 0 ], vertexNormals[ 1 ], vertexNormals[ 2 ] ); + + } else { + + var normal = face.normal; + + this.normals.push( normal, normal, normal ); + + } + + var vertexColors = face.vertexColors; + + if ( vertexColors.length === 3 ) { + + this.colors.push( vertexColors[ 0 ], vertexColors[ 1 ], vertexColors[ 2 ] ); + + } else { + + var color = face.color; + + this.colors.push( color, color, color ); + + } + + if ( hasFaceVertexUv === true ) { + + var vertexUvs = faceVertexUvs[ 0 ][ i ]; + + if ( vertexUvs !== undefined ) { + + this.uvs.push( vertexUvs[ 0 ], vertexUvs[ 1 ], vertexUvs[ 2 ] ); + + } else { + + console.warn( 'THREE.DirectGeometry.fromGeometry(): Undefined vertexUv ', i ); + + this.uvs.push( new Vector2(), new Vector2(), new Vector2() ); + + } + + } + + if ( hasFaceVertexUv2 === true ) { + + var vertexUvs = faceVertexUvs[ 1 ][ i ]; + + if ( vertexUvs !== undefined ) { + + this.uvs2.push( vertexUvs[ 0 ], vertexUvs[ 1 ], vertexUvs[ 2 ] ); + + } else { + + console.warn( 'THREE.DirectGeometry.fromGeometry(): Undefined vertexUv2 ', i ); + + this.uvs2.push( new Vector2(), new Vector2(), new Vector2() ); + + } + + } + + // morphs + + for ( var j = 0; j < morphTargetsLength; j ++ ) { + + var morphTarget = morphTargets[ j ].vertices; + + morphTargetsPosition[ j ].push( morphTarget[ face.a ], morphTarget[ face.b ], morphTarget[ face.c ] ); + + } + + for ( var j = 0; j < morphNormalsLength; j ++ ) { + + var morphNormal = morphNormals[ j ].vertexNormals[ i ]; + + morphTargetsNormal[ j ].push( morphNormal.a, morphNormal.b, morphNormal.c ); + + } + + // skins + + if ( hasSkinIndices ) { + + this.skinIndices.push( skinIndices[ face.a ], skinIndices[ face.b ], skinIndices[ face.c ] ); + + } + + if ( hasSkinWeights ) { + + this.skinWeights.push( skinWeights[ face.a ], skinWeights[ face.b ], skinWeights[ face.c ] ); + + } + + } + + this.computeGroups( geometry ); + + this.verticesNeedUpdate = geometry.verticesNeedUpdate; + this.normalsNeedUpdate = geometry.normalsNeedUpdate; + this.colorsNeedUpdate = geometry.colorsNeedUpdate; + this.uvsNeedUpdate = geometry.uvsNeedUpdate; + this.groupsNeedUpdate = geometry.groupsNeedUpdate; + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function arrayMax( array ) { + + if ( array.length === 0 ) return - Infinity; + + var max = array[ 0 ]; + + for ( var i = 1, l = array.length; i < l; ++ i ) { + + if ( array[ i ] > max ) max = array[ i ]; + + } + + return max; + + } + + /** + * @author alteredq / http://alteredqualia.com/ + * @author mrdoob / http://mrdoob.com/ + */ + + var bufferGeometryId = 1; // BufferGeometry uses odd numbers as Id + + function BufferGeometry() { + + Object.defineProperty( this, 'id', { value: bufferGeometryId += 2 } ); + + this.uuid = _Math.generateUUID(); + + this.name = ''; + this.type = 'BufferGeometry'; + + this.index = null; + this.attributes = {}; + + this.morphAttributes = {}; + + this.groups = []; + + this.boundingBox = null; + this.boundingSphere = null; + + this.drawRange = { start: 0, count: Infinity }; + + this.userData = {}; + + } + + BufferGeometry.prototype = Object.assign( Object.create( EventDispatcher.prototype ), { + + constructor: BufferGeometry, + + isBufferGeometry: true, + + getIndex: function () { + + return this.index; + + }, + + setIndex: function ( index ) { + + if ( Array.isArray( index ) ) { + + this.index = new ( arrayMax( index ) > 65535 ? Uint32BufferAttribute : Uint16BufferAttribute )( index, 1 ); + + } else { + + this.index = index; + + } + + }, + + addAttribute: function ( name, attribute ) { + + if ( ! ( attribute && attribute.isBufferAttribute ) && ! ( attribute && attribute.isInterleavedBufferAttribute ) ) { + + console.warn( 'THREE.BufferGeometry: .addAttribute() now expects ( name, attribute ).' ); + + return this.addAttribute( name, new BufferAttribute( arguments[ 1 ], arguments[ 2 ] ) ); + + } + + if ( name === 'index' ) { + + console.warn( 'THREE.BufferGeometry.addAttribute: Use .setIndex() for index attribute.' ); + this.setIndex( attribute ); + + return this; + + } + + this.attributes[ name ] = attribute; + + return this; + + }, + + getAttribute: function ( name ) { + + return this.attributes[ name ]; + + }, + + removeAttribute: function ( name ) { + + delete this.attributes[ name ]; + + return this; + + }, + + addGroup: function ( start, count, materialIndex ) { + + this.groups.push( { + + start: start, + count: count, + materialIndex: materialIndex !== undefined ? materialIndex : 0 + + } ); + + }, + + clearGroups: function () { + + this.groups = []; + + }, + + setDrawRange: function ( start, count ) { + + this.drawRange.start = start; + this.drawRange.count = count; + + }, + + applyMatrix: function ( matrix ) { + + var position = this.attributes.position; + + if ( position !== undefined ) { + + matrix.applyToBufferAttribute( position ); + position.needsUpdate = true; + + } + + var normal = this.attributes.normal; + + if ( normal !== undefined ) { + + var normalMatrix = new Matrix3().getNormalMatrix( matrix ); + + normalMatrix.applyToBufferAttribute( normal ); + normal.needsUpdate = true; + + } + + if ( this.boundingBox !== null ) { + + this.computeBoundingBox(); + + } + + if ( this.boundingSphere !== null ) { + + this.computeBoundingSphere(); + + } + + return this; + + }, + + rotateX: function () { + + // rotate geometry around world x-axis + + var m1 = new Matrix4(); + + return function rotateX( angle ) { + + m1.makeRotationX( angle ); + + this.applyMatrix( m1 ); + + return this; + + }; + + }(), + + rotateY: function () { + + // rotate geometry around world y-axis + + var m1 = new Matrix4(); + + return function rotateY( angle ) { + + m1.makeRotationY( angle ); + + this.applyMatrix( m1 ); + + return this; + + }; + + }(), + + rotateZ: function () { + + // rotate geometry around world z-axis + + var m1 = new Matrix4(); + + return function rotateZ( angle ) { + + m1.makeRotationZ( angle ); + + this.applyMatrix( m1 ); + + return this; + + }; + + }(), + + translate: function () { + + // translate geometry + + var m1 = new Matrix4(); + + return function translate( x, y, z ) { + + m1.makeTranslation( x, y, z ); + + this.applyMatrix( m1 ); + + return this; + + }; + + }(), + + scale: function () { + + // scale geometry + + var m1 = new Matrix4(); + + return function scale( x, y, z ) { + + m1.makeScale( x, y, z ); + + this.applyMatrix( m1 ); + + return this; + + }; + + }(), + + lookAt: function () { + + var obj = new Object3D(); + + return function lookAt( vector ) { + + obj.lookAt( vector ); + + obj.updateMatrix(); + + this.applyMatrix( obj.matrix ); + + }; + + }(), + + center: function () { + + var offset = new Vector3(); + + return function center() { + + this.computeBoundingBox(); + + this.boundingBox.getCenter( offset ).negate(); + + this.translate( offset.x, offset.y, offset.z ); + + return this; + + }; + + }(), + + setFromObject: function ( object ) { + + // console.log( 'THREE.BufferGeometry.setFromObject(). Converting', object, this ); + + var geometry = object.geometry; + + if ( object.isPoints || object.isLine ) { + + var positions = new Float32BufferAttribute( geometry.vertices.length * 3, 3 ); + var colors = new Float32BufferAttribute( geometry.colors.length * 3, 3 ); + + this.addAttribute( 'position', positions.copyVector3sArray( geometry.vertices ) ); + this.addAttribute( 'color', colors.copyColorsArray( geometry.colors ) ); + + if ( geometry.lineDistances && geometry.lineDistances.length === geometry.vertices.length ) { + + var lineDistances = new Float32BufferAttribute( geometry.lineDistances.length, 1 ); + + this.addAttribute( 'lineDistance', lineDistances.copyArray( geometry.lineDistances ) ); + + } + + if ( geometry.boundingSphere !== null ) { + + this.boundingSphere = geometry.boundingSphere.clone(); + + } + + if ( geometry.boundingBox !== null ) { + + this.boundingBox = geometry.boundingBox.clone(); + + } + + } else if ( object.isMesh ) { + + if ( geometry && geometry.isGeometry ) { + + this.fromGeometry( geometry ); + + } + + } + + return this; + + }, + + setFromPoints: function ( points ) { + + var position = []; + + for ( var i = 0, l = points.length; i < l; i ++ ) { + + var point = points[ i ]; + position.push( point.x, point.y, point.z || 0 ); + + } + + this.addAttribute( 'position', new Float32BufferAttribute( position, 3 ) ); + + return this; + + }, + + updateFromObject: function ( object ) { + + var geometry = object.geometry; + + if ( object.isMesh ) { + + var direct = geometry.__directGeometry; + + if ( geometry.elementsNeedUpdate === true ) { + + direct = undefined; + geometry.elementsNeedUpdate = false; + + } + + if ( direct === undefined ) { + + return this.fromGeometry( geometry ); + + } + + direct.verticesNeedUpdate = geometry.verticesNeedUpdate; + direct.normalsNeedUpdate = geometry.normalsNeedUpdate; + direct.colorsNeedUpdate = geometry.colorsNeedUpdate; + direct.uvsNeedUpdate = geometry.uvsNeedUpdate; + direct.groupsNeedUpdate = geometry.groupsNeedUpdate; + + geometry.verticesNeedUpdate = false; + geometry.normalsNeedUpdate = false; + geometry.colorsNeedUpdate = false; + geometry.uvsNeedUpdate = false; + geometry.groupsNeedUpdate = false; + + geometry = direct; + + } + + var attribute; + + if ( geometry.verticesNeedUpdate === true ) { + + attribute = this.attributes.position; + + if ( attribute !== undefined ) { + + attribute.copyVector3sArray( geometry.vertices ); + attribute.needsUpdate = true; + + } + + geometry.verticesNeedUpdate = false; + + } + + if ( geometry.normalsNeedUpdate === true ) { + + attribute = this.attributes.normal; + + if ( attribute !== undefined ) { + + attribute.copyVector3sArray( geometry.normals ); + attribute.needsUpdate = true; + + } + + geometry.normalsNeedUpdate = false; + + } + + if ( geometry.colorsNeedUpdate === true ) { + + attribute = this.attributes.color; + + if ( attribute !== undefined ) { + + attribute.copyColorsArray( geometry.colors ); + attribute.needsUpdate = true; + + } + + geometry.colorsNeedUpdate = false; + + } + + if ( geometry.uvsNeedUpdate ) { + + attribute = this.attributes.uv; + + if ( attribute !== undefined ) { + + attribute.copyVector2sArray( geometry.uvs ); + attribute.needsUpdate = true; + + } + + geometry.uvsNeedUpdate = false; + + } + + if ( geometry.lineDistancesNeedUpdate ) { + + attribute = this.attributes.lineDistance; + + if ( attribute !== undefined ) { + + attribute.copyArray( geometry.lineDistances ); + attribute.needsUpdate = true; + + } + + geometry.lineDistancesNeedUpdate = false; + + } + + if ( geometry.groupsNeedUpdate ) { + + geometry.computeGroups( object.geometry ); + this.groups = geometry.groups; + + geometry.groupsNeedUpdate = false; + + } + + return this; + + }, + + fromGeometry: function ( geometry ) { + + geometry.__directGeometry = new DirectGeometry().fromGeometry( geometry ); + + return this.fromDirectGeometry( geometry.__directGeometry ); + + }, + + fromDirectGeometry: function ( geometry ) { + + var positions = new Float32Array( geometry.vertices.length * 3 ); + this.addAttribute( 'position', new BufferAttribute( positions, 3 ).copyVector3sArray( geometry.vertices ) ); + + if ( geometry.normals.length > 0 ) { + + var normals = new Float32Array( geometry.normals.length * 3 ); + this.addAttribute( 'normal', new BufferAttribute( normals, 3 ).copyVector3sArray( geometry.normals ) ); + + } + + if ( geometry.colors.length > 0 ) { + + var colors = new Float32Array( geometry.colors.length * 3 ); + this.addAttribute( 'color', new BufferAttribute( colors, 3 ).copyColorsArray( geometry.colors ) ); + + } + + if ( geometry.uvs.length > 0 ) { + + var uvs = new Float32Array( geometry.uvs.length * 2 ); + this.addAttribute( 'uv', new BufferAttribute( uvs, 2 ).copyVector2sArray( geometry.uvs ) ); + + } + + if ( geometry.uvs2.length > 0 ) { + + var uvs2 = new Float32Array( geometry.uvs2.length * 2 ); + this.addAttribute( 'uv2', new BufferAttribute( uvs2, 2 ).copyVector2sArray( geometry.uvs2 ) ); + + } + + // groups + + this.groups = geometry.groups; + + // morphs + + for ( var name in geometry.morphTargets ) { + + var array = []; + var morphTargets = geometry.morphTargets[ name ]; + + for ( var i = 0, l = morphTargets.length; i < l; i ++ ) { + + var morphTarget = morphTargets[ i ]; + + var attribute = new Float32BufferAttribute( morphTarget.length * 3, 3 ); + + array.push( attribute.copyVector3sArray( morphTarget ) ); + + } + + this.morphAttributes[ name ] = array; + + } + + // skinning + + if ( geometry.skinIndices.length > 0 ) { + + var skinIndices = new Float32BufferAttribute( geometry.skinIndices.length * 4, 4 ); + this.addAttribute( 'skinIndex', skinIndices.copyVector4sArray( geometry.skinIndices ) ); + + } + + if ( geometry.skinWeights.length > 0 ) { + + var skinWeights = new Float32BufferAttribute( geometry.skinWeights.length * 4, 4 ); + this.addAttribute( 'skinWeight', skinWeights.copyVector4sArray( geometry.skinWeights ) ); + + } + + // + + if ( geometry.boundingSphere !== null ) { + + this.boundingSphere = geometry.boundingSphere.clone(); + + } + + if ( geometry.boundingBox !== null ) { + + this.boundingBox = geometry.boundingBox.clone(); + + } + + return this; + + }, + + computeBoundingBox: function () { + + if ( this.boundingBox === null ) { + + this.boundingBox = new Box3(); + + } + + var position = this.attributes.position; + + if ( position !== undefined ) { + + this.boundingBox.setFromBufferAttribute( position ); + + } else { + + this.boundingBox.makeEmpty(); + + } + + if ( isNaN( this.boundingBox.min.x ) || isNaN( this.boundingBox.min.y ) || isNaN( this.boundingBox.min.z ) ) { + + console.error( 'THREE.BufferGeometry.computeBoundingBox: Computed min/max have NaN values. The "position" attribute is likely to have NaN values.', this ); + + } + + }, + + computeBoundingSphere: function () { + + var box = new Box3(); + var vector = new Vector3(); + + return function computeBoundingSphere() { + + if ( this.boundingSphere === null ) { + + this.boundingSphere = new Sphere(); + + } + + var position = this.attributes.position; + + if ( position ) { + + var center = this.boundingSphere.center; + + box.setFromBufferAttribute( position ); + box.getCenter( center ); + + // hoping to find a boundingSphere with a radius smaller than the + // boundingSphere of the boundingBox: sqrt(3) smaller in the best case + + var maxRadiusSq = 0; + + for ( var i = 0, il = position.count; i < il; i ++ ) { + + vector.x = position.getX( i ); + vector.y = position.getY( i ); + vector.z = position.getZ( i ); + maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( vector ) ); + + } + + this.boundingSphere.radius = Math.sqrt( maxRadiusSq ); + + if ( isNaN( this.boundingSphere.radius ) ) { + + console.error( 'THREE.BufferGeometry.computeBoundingSphere(): Computed radius is NaN. The "position" attribute is likely to have NaN values.', this ); + + } + + } + + }; + + }(), + + computeFaceNormals: function () { + + // backwards compatibility + + }, + + computeVertexNormals: function () { + + var index = this.index; + var attributes = this.attributes; + var groups = this.groups; + + if ( attributes.position ) { + + var positions = attributes.position.array; + + if ( attributes.normal === undefined ) { + + this.addAttribute( 'normal', new BufferAttribute( new Float32Array( positions.length ), 3 ) ); + + } else { + + // reset existing normals to zero + + var array = attributes.normal.array; + + for ( var i = 0, il = array.length; i < il; i ++ ) { + + array[ i ] = 0; + + } + + } + + var normals = attributes.normal.array; + + var vA, vB, vC; + var pA = new Vector3(), pB = new Vector3(), pC = new Vector3(); + var cb = new Vector3(), ab = new Vector3(); + + // indexed elements + + if ( index ) { + + var indices = index.array; + + if ( groups.length === 0 ) { + + this.addGroup( 0, indices.length ); + + } + + for ( var j = 0, jl = groups.length; j < jl; ++ j ) { + + var group = groups[ j ]; + + var start = group.start; + var count = group.count; + + for ( var i = start, il = start + count; i < il; i += 3 ) { + + vA = indices[ i + 0 ] * 3; + vB = indices[ i + 1 ] * 3; + vC = indices[ i + 2 ] * 3; + + pA.fromArray( positions, vA ); + pB.fromArray( positions, vB ); + pC.fromArray( positions, vC ); + + cb.subVectors( pC, pB ); + ab.subVectors( pA, pB ); + cb.cross( ab ); + + normals[ vA ] += cb.x; + normals[ vA + 1 ] += cb.y; + normals[ vA + 2 ] += cb.z; + + normals[ vB ] += cb.x; + normals[ vB + 1 ] += cb.y; + normals[ vB + 2 ] += cb.z; + + normals[ vC ] += cb.x; + normals[ vC + 1 ] += cb.y; + normals[ vC + 2 ] += cb.z; + + } + + } + + } else { + + // non-indexed elements (unconnected triangle soup) + + for ( var i = 0, il = positions.length; i < il; i += 9 ) { + + pA.fromArray( positions, i ); + pB.fromArray( positions, i + 3 ); + pC.fromArray( positions, i + 6 ); + + cb.subVectors( pC, pB ); + ab.subVectors( pA, pB ); + cb.cross( ab ); + + normals[ i ] = cb.x; + normals[ i + 1 ] = cb.y; + normals[ i + 2 ] = cb.z; + + normals[ i + 3 ] = cb.x; + normals[ i + 4 ] = cb.y; + normals[ i + 5 ] = cb.z; + + normals[ i + 6 ] = cb.x; + normals[ i + 7 ] = cb.y; + normals[ i + 8 ] = cb.z; + + } + + } + + this.normalizeNormals(); + + attributes.normal.needsUpdate = true; + + } + + }, + + merge: function ( geometry, offset ) { + + if ( ! ( geometry && geometry.isBufferGeometry ) ) { + + console.error( 'THREE.BufferGeometry.merge(): geometry not an instance of THREE.BufferGeometry.', geometry ); + return; + + } + + if ( offset === undefined ) { + + offset = 0; + + console.warn( + 'THREE.BufferGeometry.merge(): Overwriting original geometry, starting at offset=0. ' + + 'Use BufferGeometryUtils.mergeBufferGeometries() for lossless merge.' + ); + + } + + var attributes = this.attributes; + + for ( var key in attributes ) { + + if ( geometry.attributes[ key ] === undefined ) continue; + + var attribute1 = attributes[ key ]; + var attributeArray1 = attribute1.array; + + var attribute2 = geometry.attributes[ key ]; + var attributeArray2 = attribute2.array; + + var attributeSize = attribute2.itemSize; + + for ( var i = 0, j = attributeSize * offset; i < attributeArray2.length; i ++, j ++ ) { + + attributeArray1[ j ] = attributeArray2[ i ]; + + } + + } + + return this; + + }, + + normalizeNormals: function () { + + var vector = new Vector3(); + + return function normalizeNormals() { + + var normals = this.attributes.normal; + + for ( var i = 0, il = normals.count; i < il; i ++ ) { + + vector.x = normals.getX( i ); + vector.y = normals.getY( i ); + vector.z = normals.getZ( i ); + + vector.normalize(); + + normals.setXYZ( i, vector.x, vector.y, vector.z ); + + } + + }; + + }(), + + toNonIndexed: function () { + + if ( this.index === null ) { + + console.warn( 'THREE.BufferGeometry.toNonIndexed(): Geometry is already non-indexed.' ); + return this; + + } + + var geometry2 = new BufferGeometry(); + + var indices = this.index.array; + var attributes = this.attributes; + + for ( var name in attributes ) { + + var attribute = attributes[ name ]; + + var array = attribute.array; + var itemSize = attribute.itemSize; + + var array2 = new array.constructor( indices.length * itemSize ); + + var index = 0, index2 = 0; + + for ( var i = 0, l = indices.length; i < l; i ++ ) { + + index = indices[ i ] * itemSize; + + for ( var j = 0; j < itemSize; j ++ ) { + + array2[ index2 ++ ] = array[ index ++ ]; + + } + + } + + geometry2.addAttribute( name, new BufferAttribute( array2, itemSize ) ); + + } + + var groups = this.groups; + + for ( var i = 0, l = groups.length; i < l; i ++ ) { + + var group = groups[ i ]; + geometry2.addGroup( group.start, group.count, group.materialIndex ); + + } + + return geometry2; + + }, + + toJSON: function () { + + var data = { + metadata: { + version: 4.5, + type: 'BufferGeometry', + generator: 'BufferGeometry.toJSON' + } + }; + + // standard BufferGeometry serialization + + data.uuid = this.uuid; + data.type = this.type; + if ( this.name !== '' ) data.name = this.name; + if ( Object.keys( this.userData ).length > 0 ) data.userData = this.userData; + + if ( this.parameters !== undefined ) { + + var parameters = this.parameters; + + for ( var key in parameters ) { + + if ( parameters[ key ] !== undefined ) data[ key ] = parameters[ key ]; + + } + + return data; + + } + + data.data = { attributes: {} }; + + var index = this.index; + + if ( index !== null ) { + + var array = Array.prototype.slice.call( index.array ); + + data.data.index = { + type: index.array.constructor.name, + array: array + }; + + } + + var attributes = this.attributes; + + for ( var key in attributes ) { + + var attribute = attributes[ key ]; + + var array = Array.prototype.slice.call( attribute.array ); + + data.data.attributes[ key ] = { + itemSize: attribute.itemSize, + type: attribute.array.constructor.name, + array: array, + normalized: attribute.normalized + }; + + } + + var groups = this.groups; + + if ( groups.length > 0 ) { + + data.data.groups = JSON.parse( JSON.stringify( groups ) ); + + } + + var boundingSphere = this.boundingSphere; + + if ( boundingSphere !== null ) { + + data.data.boundingSphere = { + center: boundingSphere.center.toArray(), + radius: boundingSphere.radius + }; + + } + + return data; + + }, + + clone: function () { + + /* + // Handle primitives + + var parameters = this.parameters; + + if ( parameters !== undefined ) { + + var values = []; + + for ( var key in parameters ) { + + values.push( parameters[ key ] ); + + } + + var geometry = Object.create( this.constructor.prototype ); + this.constructor.apply( geometry, values ); + return geometry; + + } + + return new this.constructor().copy( this ); + */ + + return new BufferGeometry().copy( this ); + + }, + + copy: function ( source ) { + + var name, i, l; + + // reset + + this.index = null; + this.attributes = {}; + this.morphAttributes = {}; + this.groups = []; + this.boundingBox = null; + this.boundingSphere = null; + + // name + + this.name = source.name; + + // index + + var index = source.index; + + if ( index !== null ) { + + this.setIndex( index.clone() ); + + } + + // attributes + + var attributes = source.attributes; + + for ( name in attributes ) { + + var attribute = attributes[ name ]; + this.addAttribute( name, attribute.clone() ); + + } + + // morph attributes + + var morphAttributes = source.morphAttributes; + + for ( name in morphAttributes ) { + + var array = []; + var morphAttribute = morphAttributes[ name ]; // morphAttribute: array of Float32BufferAttributes + + for ( i = 0, l = morphAttribute.length; i < l; i ++ ) { + + array.push( morphAttribute[ i ].clone() ); + + } + + this.morphAttributes[ name ] = array; + + } + + // groups + + var groups = source.groups; + + for ( i = 0, l = groups.length; i < l; i ++ ) { + + var group = groups[ i ]; + this.addGroup( group.start, group.count, group.materialIndex ); + + } + + // bounding box + + var boundingBox = source.boundingBox; + + if ( boundingBox !== null ) { + + this.boundingBox = boundingBox.clone(); + + } + + // bounding sphere + + var boundingSphere = source.boundingSphere; + + if ( boundingSphere !== null ) { + + this.boundingSphere = boundingSphere.clone(); + + } + + // draw range + + this.drawRange.start = source.drawRange.start; + this.drawRange.count = source.drawRange.count; + + // user data + + this.userData = source.userData; + + return this; + + }, + + dispose: function () { + + this.dispatchEvent( { type: 'dispose' } ); + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author Mugen87 / https://github.com/Mugen87 + */ + + // BoxGeometry + + function BoxGeometry( width, height, depth, widthSegments, heightSegments, depthSegments ) { + + Geometry.call( this ); + + this.type = 'BoxGeometry'; + + this.parameters = { + width: width, + height: height, + depth: depth, + widthSegments: widthSegments, + heightSegments: heightSegments, + depthSegments: depthSegments + }; + + this.fromBufferGeometry( new BoxBufferGeometry( width, height, depth, widthSegments, heightSegments, depthSegments ) ); + this.mergeVertices(); + + } + + BoxGeometry.prototype = Object.create( Geometry.prototype ); + BoxGeometry.prototype.constructor = BoxGeometry; + + // BoxBufferGeometry + + function BoxBufferGeometry( width, height, depth, widthSegments, heightSegments, depthSegments ) { + + BufferGeometry.call( this ); + + this.type = 'BoxBufferGeometry'; + + this.parameters = { + width: width, + height: height, + depth: depth, + widthSegments: widthSegments, + heightSegments: heightSegments, + depthSegments: depthSegments + }; + + var scope = this; + + width = width || 1; + height = height || 1; + depth = depth || 1; + + // segments + + widthSegments = Math.floor( widthSegments ) || 1; + heightSegments = Math.floor( heightSegments ) || 1; + depthSegments = Math.floor( depthSegments ) || 1; + + // buffers + + var indices = []; + var vertices = []; + var normals = []; + var uvs = []; + + // helper variables + + var numberOfVertices = 0; + var groupStart = 0; + + // build each side of the box geometry + + buildPlane( 'z', 'y', 'x', - 1, - 1, depth, height, width, depthSegments, heightSegments, 0 ); // px + buildPlane( 'z', 'y', 'x', 1, - 1, depth, height, - width, depthSegments, heightSegments, 1 ); // nx + buildPlane( 'x', 'z', 'y', 1, 1, width, depth, height, widthSegments, depthSegments, 2 ); // py + buildPlane( 'x', 'z', 'y', 1, - 1, width, depth, - height, widthSegments, depthSegments, 3 ); // ny + buildPlane( 'x', 'y', 'z', 1, - 1, width, height, depth, widthSegments, heightSegments, 4 ); // pz + buildPlane( 'x', 'y', 'z', - 1, - 1, width, height, - depth, widthSegments, heightSegments, 5 ); // nz + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + function buildPlane( u, v, w, udir, vdir, width, height, depth, gridX, gridY, materialIndex ) { + + var segmentWidth = width / gridX; + var segmentHeight = height / gridY; + + var widthHalf = width / 2; + var heightHalf = height / 2; + var depthHalf = depth / 2; + + var gridX1 = gridX + 1; + var gridY1 = gridY + 1; + + var vertexCounter = 0; + var groupCount = 0; + + var ix, iy; + + var vector = new Vector3(); + + // generate vertices, normals and uvs + + for ( iy = 0; iy < gridY1; iy ++ ) { + + var y = iy * segmentHeight - heightHalf; + + for ( ix = 0; ix < gridX1; ix ++ ) { + + var x = ix * segmentWidth - widthHalf; + + // set values to correct vector component + + vector[ u ] = x * udir; + vector[ v ] = y * vdir; + vector[ w ] = depthHalf; + + // now apply vector to vertex buffer + + vertices.push( vector.x, vector.y, vector.z ); + + // set values to correct vector component + + vector[ u ] = 0; + vector[ v ] = 0; + vector[ w ] = depth > 0 ? 1 : - 1; + + // now apply vector to normal buffer + + normals.push( vector.x, vector.y, vector.z ); + + // uvs + + uvs.push( ix / gridX ); + uvs.push( 1 - ( iy / gridY ) ); + + // counters + + vertexCounter += 1; + + } + + } + + // indices + + // 1. you need three indices to draw a single face + // 2. a single segment consists of two faces + // 3. so we need to generate six (2*3) indices per segment + + for ( iy = 0; iy < gridY; iy ++ ) { + + for ( ix = 0; ix < gridX; ix ++ ) { + + var a = numberOfVertices + ix + gridX1 * iy; + var b = numberOfVertices + ix + gridX1 * ( iy + 1 ); + var c = numberOfVertices + ( ix + 1 ) + gridX1 * ( iy + 1 ); + var d = numberOfVertices + ( ix + 1 ) + gridX1 * iy; + + // faces + + indices.push( a, b, d ); + indices.push( b, c, d ); + + // increase counter + + groupCount += 6; + + } + + } + + // add a group to the geometry. this will ensure multi material support + + scope.addGroup( groupStart, groupCount, materialIndex ); + + // calculate new start value for groups + + groupStart += groupCount; + + // update total number of vertices + + numberOfVertices += vertexCounter; + + } + + } + + BoxBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + BoxBufferGeometry.prototype.constructor = BoxBufferGeometry; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author Mugen87 / https://github.com/Mugen87 + */ + + // PlaneGeometry + + function PlaneGeometry( width, height, widthSegments, heightSegments ) { + + Geometry.call( this ); + + this.type = 'PlaneGeometry'; + + this.parameters = { + width: width, + height: height, + widthSegments: widthSegments, + heightSegments: heightSegments + }; + + this.fromBufferGeometry( new PlaneBufferGeometry( width, height, widthSegments, heightSegments ) ); + this.mergeVertices(); + + } + + PlaneGeometry.prototype = Object.create( Geometry.prototype ); + PlaneGeometry.prototype.constructor = PlaneGeometry; + + // PlaneBufferGeometry + + function PlaneBufferGeometry( width, height, widthSegments, heightSegments ) { + + BufferGeometry.call( this ); + + this.type = 'PlaneBufferGeometry'; + + this.parameters = { + width: width, + height: height, + widthSegments: widthSegments, + heightSegments: heightSegments + }; + + width = width || 1; + height = height || 1; + + var width_half = width / 2; + var height_half = height / 2; + + var gridX = Math.floor( widthSegments ) || 1; + var gridY = Math.floor( heightSegments ) || 1; + + var gridX1 = gridX + 1; + var gridY1 = gridY + 1; + + var segment_width = width / gridX; + var segment_height = height / gridY; + + var ix, iy; + + // buffers + + var indices = []; + var vertices = []; + var normals = []; + var uvs = []; + + // generate vertices, normals and uvs + + for ( iy = 0; iy < gridY1; iy ++ ) { + + var y = iy * segment_height - height_half; + + for ( ix = 0; ix < gridX1; ix ++ ) { + + var x = ix * segment_width - width_half; + + vertices.push( x, - y, 0 ); + + normals.push( 0, 0, 1 ); + + uvs.push( ix / gridX ); + uvs.push( 1 - ( iy / gridY ) ); + + } + + } + + // indices + + for ( iy = 0; iy < gridY; iy ++ ) { + + for ( ix = 0; ix < gridX; ix ++ ) { + + var a = ix + gridX1 * iy; + var b = ix + gridX1 * ( iy + 1 ); + var c = ( ix + 1 ) + gridX1 * ( iy + 1 ); + var d = ( ix + 1 ) + gridX1 * iy; + + // faces + + indices.push( a, b, d ); + indices.push( b, c, d ); + + } + + } + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + } + + PlaneBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + PlaneBufferGeometry.prototype.constructor = PlaneBufferGeometry; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + */ + + var materialId = 0; + + function Material() { + + Object.defineProperty( this, 'id', { value: materialId ++ } ); + + this.uuid = _Math.generateUUID(); + + this.name = ''; + this.type = 'Material'; + + this.fog = true; + this.lights = true; + + this.blending = NormalBlending; + this.side = FrontSide; + this.flatShading = false; + this.vertexColors = NoColors; // THREE.NoColors, THREE.VertexColors, THREE.FaceColors + + this.opacity = 1; + this.transparent = false; + + this.blendSrc = SrcAlphaFactor; + this.blendDst = OneMinusSrcAlphaFactor; + this.blendEquation = AddEquation; + this.blendSrcAlpha = null; + this.blendDstAlpha = null; + this.blendEquationAlpha = null; + + this.depthFunc = LessEqualDepth; + this.depthTest = true; + this.depthWrite = true; + + this.clippingPlanes = null; + this.clipIntersection = false; + this.clipShadows = false; + + this.shadowSide = null; + + this.colorWrite = true; + + this.precision = null; // override the renderer's default precision for this material + + this.polygonOffset = false; + this.polygonOffsetFactor = 0; + this.polygonOffsetUnits = 0; + + this.dithering = false; + + this.alphaTest = 0; + this.premultipliedAlpha = false; + + this.overdraw = 0; // Overdrawn pixels (typically between 0 and 1) for fixing antialiasing gaps in CanvasRenderer + + this.visible = true; + + this.userData = {}; + + this.needsUpdate = true; + + } + + Material.prototype = Object.assign( Object.create( EventDispatcher.prototype ), { + + constructor: Material, + + isMaterial: true, + + onBeforeCompile: function () {}, + + setValues: function ( values ) { + + if ( values === undefined ) return; + + for ( var key in values ) { + + var newValue = values[ key ]; + + if ( newValue === undefined ) { + + console.warn( "THREE.Material: '" + key + "' parameter is undefined." ); + continue; + + } + + // for backward compatability if shading is set in the constructor + if ( key === 'shading' ) { + + console.warn( 'THREE.' + this.type + ': .shading has been removed. Use the boolean .flatShading instead.' ); + this.flatShading = ( newValue === FlatShading ) ? true : false; + continue; + + } + + var currentValue = this[ key ]; + + if ( currentValue === undefined ) { + + console.warn( "THREE." + this.type + ": '" + key + "' is not a property of this material." ); + continue; + + } + + if ( currentValue && currentValue.isColor ) { + + currentValue.set( newValue ); + + } else if ( ( currentValue && currentValue.isVector3 ) && ( newValue && newValue.isVector3 ) ) { + + currentValue.copy( newValue ); + + } else if ( key === 'overdraw' ) { + + // ensure overdraw is backwards-compatible with legacy boolean type + this[ key ] = Number( newValue ); + + } else { + + this[ key ] = newValue; + + } + + } + + }, + + toJSON: function ( meta ) { + + var isRoot = ( meta === undefined || typeof meta === 'string' ); + + if ( isRoot ) { + + meta = { + textures: {}, + images: {} + }; + + } + + var data = { + metadata: { + version: 4.5, + type: 'Material', + generator: 'Material.toJSON' + } + }; + + // standard Material serialization + data.uuid = this.uuid; + data.type = this.type; + + if ( this.name !== '' ) data.name = this.name; + + if ( this.color && this.color.isColor ) data.color = this.color.getHex(); + + if ( this.roughness !== undefined ) data.roughness = this.roughness; + if ( this.metalness !== undefined ) data.metalness = this.metalness; + + if ( this.emissive && this.emissive.isColor ) data.emissive = this.emissive.getHex(); + if ( this.emissiveIntensity !== 1 ) data.emissiveIntensity = this.emissiveIntensity; + + if ( this.specular && this.specular.isColor ) data.specular = this.specular.getHex(); + if ( this.shininess !== undefined ) data.shininess = this.shininess; + if ( this.clearCoat !== undefined ) data.clearCoat = this.clearCoat; + if ( this.clearCoatRoughness !== undefined ) data.clearCoatRoughness = this.clearCoatRoughness; + + if ( this.map && this.map.isTexture ) data.map = this.map.toJSON( meta ).uuid; + if ( this.alphaMap && this.alphaMap.isTexture ) data.alphaMap = this.alphaMap.toJSON( meta ).uuid; + if ( this.lightMap && this.lightMap.isTexture ) data.lightMap = this.lightMap.toJSON( meta ).uuid; + + if ( this.aoMap && this.aoMap.isTexture ) { + + data.aoMap = this.aoMap.toJSON( meta ).uuid; + data.aoMapIntensity = this.aoMapIntensity; + + } + + if ( this.bumpMap && this.bumpMap.isTexture ) { + + data.bumpMap = this.bumpMap.toJSON( meta ).uuid; + data.bumpScale = this.bumpScale; + + } + + if ( this.normalMap && this.normalMap.isTexture ) { + + data.normalMap = this.normalMap.toJSON( meta ).uuid; + data.normalScale = this.normalScale.toArray(); + + } + + if ( this.displacementMap && this.displacementMap.isTexture ) { + + data.displacementMap = this.displacementMap.toJSON( meta ).uuid; + data.displacementScale = this.displacementScale; + data.displacementBias = this.displacementBias; + + } + + if ( this.roughnessMap && this.roughnessMap.isTexture ) data.roughnessMap = this.roughnessMap.toJSON( meta ).uuid; + if ( this.metalnessMap && this.metalnessMap.isTexture ) data.metalnessMap = this.metalnessMap.toJSON( meta ).uuid; + + if ( this.emissiveMap && this.emissiveMap.isTexture ) data.emissiveMap = this.emissiveMap.toJSON( meta ).uuid; + if ( this.specularMap && this.specularMap.isTexture ) data.specularMap = this.specularMap.toJSON( meta ).uuid; + + if ( this.envMap && this.envMap.isTexture ) { + + data.envMap = this.envMap.toJSON( meta ).uuid; + data.reflectivity = this.reflectivity; // Scale behind envMap + + } + + if ( this.gradientMap && this.gradientMap.isTexture ) { + + data.gradientMap = this.gradientMap.toJSON( meta ).uuid; + + } + + if ( this.size !== undefined ) data.size = this.size; + if ( this.sizeAttenuation !== undefined ) data.sizeAttenuation = this.sizeAttenuation; + + if ( this.blending !== NormalBlending ) data.blending = this.blending; + if ( this.flatShading === true ) data.flatShading = this.flatShading; + if ( this.side !== FrontSide ) data.side = this.side; + if ( this.vertexColors !== NoColors ) data.vertexColors = this.vertexColors; + + if ( this.opacity < 1 ) data.opacity = this.opacity; + if ( this.transparent === true ) data.transparent = this.transparent; + + data.depthFunc = this.depthFunc; + data.depthTest = this.depthTest; + data.depthWrite = this.depthWrite; + + // rotation (SpriteMaterial) + if ( this.rotation !== 0 ) data.rotation = this.rotation; + + if ( this.linewidth !== 1 ) data.linewidth = this.linewidth; + if ( this.dashSize !== undefined ) data.dashSize = this.dashSize; + if ( this.gapSize !== undefined ) data.gapSize = this.gapSize; + if ( this.scale !== undefined ) data.scale = this.scale; + + if ( this.dithering === true ) data.dithering = true; + + if ( this.alphaTest > 0 ) data.alphaTest = this.alphaTest; + if ( this.premultipliedAlpha === true ) data.premultipliedAlpha = this.premultipliedAlpha; + + if ( this.wireframe === true ) data.wireframe = this.wireframe; + if ( this.wireframeLinewidth > 1 ) data.wireframeLinewidth = this.wireframeLinewidth; + if ( this.wireframeLinecap !== 'round' ) data.wireframeLinecap = this.wireframeLinecap; + if ( this.wireframeLinejoin !== 'round' ) data.wireframeLinejoin = this.wireframeLinejoin; + + if ( this.morphTargets === true ) data.morphTargets = true; + if ( this.skinning === true ) data.skinning = true; + + if ( this.visible === false ) data.visible = false; + if ( JSON.stringify( this.userData ) !== '{}' ) data.userData = this.userData; + + // TODO: Copied from Object3D.toJSON + + function extractFromCache( cache ) { + + var values = []; + + for ( var key in cache ) { + + var data = cache[ key ]; + delete data.metadata; + values.push( data ); + + } + + return values; + + } + + if ( isRoot ) { + + var textures = extractFromCache( meta.textures ); + var images = extractFromCache( meta.images ); + + if ( textures.length > 0 ) data.textures = textures; + if ( images.length > 0 ) data.images = images; + + } + + return data; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( source ) { + + this.name = source.name; + + this.fog = source.fog; + this.lights = source.lights; + + this.blending = source.blending; + this.side = source.side; + this.flatShading = source.flatShading; + this.vertexColors = source.vertexColors; + + this.opacity = source.opacity; + this.transparent = source.transparent; + + this.blendSrc = source.blendSrc; + this.blendDst = source.blendDst; + this.blendEquation = source.blendEquation; + this.blendSrcAlpha = source.blendSrcAlpha; + this.blendDstAlpha = source.blendDstAlpha; + this.blendEquationAlpha = source.blendEquationAlpha; + + this.depthFunc = source.depthFunc; + this.depthTest = source.depthTest; + this.depthWrite = source.depthWrite; + + this.colorWrite = source.colorWrite; + + this.precision = source.precision; + + this.polygonOffset = source.polygonOffset; + this.polygonOffsetFactor = source.polygonOffsetFactor; + this.polygonOffsetUnits = source.polygonOffsetUnits; + + this.dithering = source.dithering; + + this.alphaTest = source.alphaTest; + this.premultipliedAlpha = source.premultipliedAlpha; + + this.overdraw = source.overdraw; + + this.visible = source.visible; + this.userData = JSON.parse( JSON.stringify( source.userData ) ); + + this.clipShadows = source.clipShadows; + this.clipIntersection = source.clipIntersection; + + var srcPlanes = source.clippingPlanes, + dstPlanes = null; + + if ( srcPlanes !== null ) { + + var n = srcPlanes.length; + dstPlanes = new Array( n ); + + for ( var i = 0; i !== n; ++ i ) + dstPlanes[ i ] = srcPlanes[ i ].clone(); + + } + + this.clippingPlanes = dstPlanes; + + this.shadowSide = source.shadowSide; + + return this; + + }, + + dispose: function () { + + this.dispatchEvent( { type: 'dispose' } ); + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + * + * parameters = { + * color: , + * opacity: , + * map: new THREE.Texture( ), + * + * lightMap: new THREE.Texture( ), + * lightMapIntensity: + * + * aoMap: new THREE.Texture( ), + * aoMapIntensity: + * + * specularMap: new THREE.Texture( ), + * + * alphaMap: new THREE.Texture( ), + * + * envMap: new THREE.CubeTexture( [posx, negx, posy, negy, posz, negz] ), + * combine: THREE.Multiply, + * reflectivity: , + * refractionRatio: , + * + * depthTest: , + * depthWrite: , + * + * wireframe: , + * wireframeLinewidth: , + * + * skinning: , + * morphTargets: + * } + */ + + function MeshBasicMaterial( parameters ) { + + Material.call( this ); + + this.type = 'MeshBasicMaterial'; + + this.color = new Color( 0xffffff ); // emissive + + this.map = null; + + this.lightMap = null; + this.lightMapIntensity = 1.0; + + this.aoMap = null; + this.aoMapIntensity = 1.0; + + this.specularMap = null; + + this.alphaMap = null; + + this.envMap = null; + this.combine = MultiplyOperation; + this.reflectivity = 1; + this.refractionRatio = 0.98; + + this.wireframe = false; + this.wireframeLinewidth = 1; + this.wireframeLinecap = 'round'; + this.wireframeLinejoin = 'round'; + + this.skinning = false; + this.morphTargets = false; + + this.lights = false; + + this.setValues( parameters ); + + } + + MeshBasicMaterial.prototype = Object.create( Material.prototype ); + MeshBasicMaterial.prototype.constructor = MeshBasicMaterial; + + MeshBasicMaterial.prototype.isMeshBasicMaterial = true; + + MeshBasicMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.color.copy( source.color ); + + this.map = source.map; + + this.lightMap = source.lightMap; + this.lightMapIntensity = source.lightMapIntensity; + + this.aoMap = source.aoMap; + this.aoMapIntensity = source.aoMapIntensity; + + this.specularMap = source.specularMap; + + this.alphaMap = source.alphaMap; + + this.envMap = source.envMap; + this.combine = source.combine; + this.reflectivity = source.reflectivity; + this.refractionRatio = source.refractionRatio; + + this.wireframe = source.wireframe; + this.wireframeLinewidth = source.wireframeLinewidth; + this.wireframeLinecap = source.wireframeLinecap; + this.wireframeLinejoin = source.wireframeLinejoin; + + this.skinning = source.skinning; + this.morphTargets = source.morphTargets; + + return this; + + }; + + /** + * @author alteredq / http://alteredqualia.com/ + * + * parameters = { + * defines: { "label" : "value" }, + * uniforms: { "parameter1": { value: 1.0 }, "parameter2": { value2: 2 } }, + * + * fragmentShader: , + * vertexShader: , + * + * wireframe: , + * wireframeLinewidth: , + * + * lights: , + * + * skinning: , + * morphTargets: , + * morphNormals: + * } + */ + + function ShaderMaterial( parameters ) { + + Material.call( this ); + + this.type = 'ShaderMaterial'; + + this.defines = {}; + this.uniforms = {}; + + this.vertexShader = 'void main() {\n\tgl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );\n}'; + this.fragmentShader = 'void main() {\n\tgl_FragColor = vec4( 1.0, 0.0, 0.0, 1.0 );\n}'; + + this.linewidth = 1; + + this.wireframe = false; + this.wireframeLinewidth = 1; + + this.fog = false; // set to use scene fog + this.lights = false; // set to use scene lights + this.clipping = false; // set to use user-defined clipping planes + + this.skinning = false; // set to use skinning attribute streams + this.morphTargets = false; // set to use morph targets + this.morphNormals = false; // set to use morph normals + + this.extensions = { + derivatives: false, // set to use derivatives + fragDepth: false, // set to use fragment depth values + drawBuffers: false, // set to use draw buffers + shaderTextureLOD: false // set to use shader texture LOD + }; + + // When rendered geometry doesn't include these attributes but the material does, + // use these default values in WebGL. This avoids errors when buffer data is missing. + this.defaultAttributeValues = { + 'color': [ 1, 1, 1 ], + 'uv': [ 0, 0 ], + 'uv2': [ 0, 0 ] + }; + + this.index0AttributeName = undefined; + this.uniformsNeedUpdate = false; + + if ( parameters !== undefined ) { + + if ( parameters.attributes !== undefined ) { + + console.error( 'THREE.ShaderMaterial: attributes should now be defined in THREE.BufferGeometry instead.' ); + + } + + this.setValues( parameters ); + + } + + } + + ShaderMaterial.prototype = Object.create( Material.prototype ); + ShaderMaterial.prototype.constructor = ShaderMaterial; + + ShaderMaterial.prototype.isShaderMaterial = true; + + ShaderMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.fragmentShader = source.fragmentShader; + this.vertexShader = source.vertexShader; + + this.uniforms = UniformsUtils.clone( source.uniforms ); + + this.defines = Object.assign( {}, source.defines ); + + this.wireframe = source.wireframe; + this.wireframeLinewidth = source.wireframeLinewidth; + + this.lights = source.lights; + this.clipping = source.clipping; + + this.skinning = source.skinning; + + this.morphTargets = source.morphTargets; + this.morphNormals = source.morphNormals; + + this.extensions = source.extensions; + + return this; + + }; + + ShaderMaterial.prototype.toJSON = function ( meta ) { + + var data = Material.prototype.toJSON.call( this, meta ); + + data.uniforms = this.uniforms; + data.vertexShader = this.vertexShader; + data.fragmentShader = this.fragmentShader; + + return data; + + }; + + /** + * @author bhouston / http://clara.io + */ + + function Ray( origin, direction ) { + + this.origin = ( origin !== undefined ) ? origin : new Vector3(); + this.direction = ( direction !== undefined ) ? direction : new Vector3(); + + } + + Object.assign( Ray.prototype, { + + set: function ( origin, direction ) { + + this.origin.copy( origin ); + this.direction.copy( direction ); + + return this; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( ray ) { + + this.origin.copy( ray.origin ); + this.direction.copy( ray.direction ); + + return this; + + }, + + at: function ( t, target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Ray: .at() target is now required' ); + target = new Vector3(); + + } + + return target.copy( this.direction ).multiplyScalar( t ).add( this.origin ); + + }, + + lookAt: function ( v ) { + + this.direction.copy( v ).sub( this.origin ).normalize(); + + return this; + + }, + + recast: function () { + + var v1 = new Vector3(); + + return function recast( t ) { + + this.origin.copy( this.at( t, v1 ) ); + + return this; + + }; + + }(), + + closestPointToPoint: function ( point, target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Ray: .closestPointToPoint() target is now required' ); + target = new Vector3(); + + } + + target.subVectors( point, this.origin ); + + var directionDistance = target.dot( this.direction ); + + if ( directionDistance < 0 ) { + + return target.copy( this.origin ); + + } + + return target.copy( this.direction ).multiplyScalar( directionDistance ).add( this.origin ); + + }, + + distanceToPoint: function ( point ) { + + return Math.sqrt( this.distanceSqToPoint( point ) ); + + }, + + distanceSqToPoint: function () { + + var v1 = new Vector3(); + + return function distanceSqToPoint( point ) { + + var directionDistance = v1.subVectors( point, this.origin ).dot( this.direction ); + + // point behind the ray + + if ( directionDistance < 0 ) { + + return this.origin.distanceToSquared( point ); + + } + + v1.copy( this.direction ).multiplyScalar( directionDistance ).add( this.origin ); + + return v1.distanceToSquared( point ); + + }; + + }(), + + distanceSqToSegment: function () { + + var segCenter = new Vector3(); + var segDir = new Vector3(); + var diff = new Vector3(); + + return function distanceSqToSegment( v0, v1, optionalPointOnRay, optionalPointOnSegment ) { + + // from http://www.geometrictools.com/GTEngine/Include/Mathematics/GteDistRaySegment.h + // It returns the min distance between the ray and the segment + // defined by v0 and v1 + // It can also set two optional targets : + // - The closest point on the ray + // - The closest point on the segment + + segCenter.copy( v0 ).add( v1 ).multiplyScalar( 0.5 ); + segDir.copy( v1 ).sub( v0 ).normalize(); + diff.copy( this.origin ).sub( segCenter ); + + var segExtent = v0.distanceTo( v1 ) * 0.5; + var a01 = - this.direction.dot( segDir ); + var b0 = diff.dot( this.direction ); + var b1 = - diff.dot( segDir ); + var c = diff.lengthSq(); + var det = Math.abs( 1 - a01 * a01 ); + var s0, s1, sqrDist, extDet; + + if ( det > 0 ) { + + // The ray and segment are not parallel. + + s0 = a01 * b1 - b0; + s1 = a01 * b0 - b1; + extDet = segExtent * det; + + if ( s0 >= 0 ) { + + if ( s1 >= - extDet ) { + + if ( s1 <= extDet ) { + + // region 0 + // Minimum at interior points of ray and segment. + + var invDet = 1 / det; + s0 *= invDet; + s1 *= invDet; + sqrDist = s0 * ( s0 + a01 * s1 + 2 * b0 ) + s1 * ( a01 * s0 + s1 + 2 * b1 ) + c; + + } else { + + // region 1 + + s1 = segExtent; + s0 = Math.max( 0, - ( a01 * s1 + b0 ) ); + sqrDist = - s0 * s0 + s1 * ( s1 + 2 * b1 ) + c; + + } + + } else { + + // region 5 + + s1 = - segExtent; + s0 = Math.max( 0, - ( a01 * s1 + b0 ) ); + sqrDist = - s0 * s0 + s1 * ( s1 + 2 * b1 ) + c; + + } + + } else { + + if ( s1 <= - extDet ) { + + // region 4 + + s0 = Math.max( 0, - ( - a01 * segExtent + b0 ) ); + s1 = ( s0 > 0 ) ? - segExtent : Math.min( Math.max( - segExtent, - b1 ), segExtent ); + sqrDist = - s0 * s0 + s1 * ( s1 + 2 * b1 ) + c; + + } else if ( s1 <= extDet ) { + + // region 3 + + s0 = 0; + s1 = Math.min( Math.max( - segExtent, - b1 ), segExtent ); + sqrDist = s1 * ( s1 + 2 * b1 ) + c; + + } else { + + // region 2 + + s0 = Math.max( 0, - ( a01 * segExtent + b0 ) ); + s1 = ( s0 > 0 ) ? segExtent : Math.min( Math.max( - segExtent, - b1 ), segExtent ); + sqrDist = - s0 * s0 + s1 * ( s1 + 2 * b1 ) + c; + + } + + } + + } else { + + // Ray and segment are parallel. + + s1 = ( a01 > 0 ) ? - segExtent : segExtent; + s0 = Math.max( 0, - ( a01 * s1 + b0 ) ); + sqrDist = - s0 * s0 + s1 * ( s1 + 2 * b1 ) + c; + + } + + if ( optionalPointOnRay ) { + + optionalPointOnRay.copy( this.direction ).multiplyScalar( s0 ).add( this.origin ); + + } + + if ( optionalPointOnSegment ) { + + optionalPointOnSegment.copy( segDir ).multiplyScalar( s1 ).add( segCenter ); + + } + + return sqrDist; + + }; + + }(), + + intersectSphere: function () { + + var v1 = new Vector3(); + + return function intersectSphere( sphere, target ) { + + v1.subVectors( sphere.center, this.origin ); + var tca = v1.dot( this.direction ); + var d2 = v1.dot( v1 ) - tca * tca; + var radius2 = sphere.radius * sphere.radius; + + if ( d2 > radius2 ) return null; + + var thc = Math.sqrt( radius2 - d2 ); + + // t0 = first intersect point - entrance on front of sphere + var t0 = tca - thc; + + // t1 = second intersect point - exit point on back of sphere + var t1 = tca + thc; + + // test to see if both t0 and t1 are behind the ray - if so, return null + if ( t0 < 0 && t1 < 0 ) return null; + + // test to see if t0 is behind the ray: + // if it is, the ray is inside the sphere, so return the second exit point scaled by t1, + // in order to always return an intersect point that is in front of the ray. + if ( t0 < 0 ) return this.at( t1, target ); + + // else t0 is in front of the ray, so return the first collision point scaled by t0 + return this.at( t0, target ); + + }; + + }(), + + intersectsSphere: function ( sphere ) { + + return this.distanceToPoint( sphere.center ) <= sphere.radius; + + }, + + distanceToPlane: function ( plane ) { + + var denominator = plane.normal.dot( this.direction ); + + if ( denominator === 0 ) { + + // line is coplanar, return origin + if ( plane.distanceToPoint( this.origin ) === 0 ) { + + return 0; + + } + + // Null is preferable to undefined since undefined means.... it is undefined + + return null; + + } + + var t = - ( this.origin.dot( plane.normal ) + plane.constant ) / denominator; + + // Return if the ray never intersects the plane + + return t >= 0 ? t : null; + + }, + + intersectPlane: function ( plane, target ) { + + var t = this.distanceToPlane( plane ); + + if ( t === null ) { + + return null; + + } + + return this.at( t, target ); + + }, + + intersectsPlane: function ( plane ) { + + // check if the ray lies on the plane first + + var distToPoint = plane.distanceToPoint( this.origin ); + + if ( distToPoint === 0 ) { + + return true; + + } + + var denominator = plane.normal.dot( this.direction ); + + if ( denominator * distToPoint < 0 ) { + + return true; + + } + + // ray origin is behind the plane (and is pointing behind it) + + return false; + + }, + + intersectBox: function ( box, target ) { + + var tmin, tmax, tymin, tymax, tzmin, tzmax; + + var invdirx = 1 / this.direction.x, + invdiry = 1 / this.direction.y, + invdirz = 1 / this.direction.z; + + var origin = this.origin; + + if ( invdirx >= 0 ) { + + tmin = ( box.min.x - origin.x ) * invdirx; + tmax = ( box.max.x - origin.x ) * invdirx; + + } else { + + tmin = ( box.max.x - origin.x ) * invdirx; + tmax = ( box.min.x - origin.x ) * invdirx; + + } + + if ( invdiry >= 0 ) { + + tymin = ( box.min.y - origin.y ) * invdiry; + tymax = ( box.max.y - origin.y ) * invdiry; + + } else { + + tymin = ( box.max.y - origin.y ) * invdiry; + tymax = ( box.min.y - origin.y ) * invdiry; + + } + + if ( ( tmin > tymax ) || ( tymin > tmax ) ) return null; + + // These lines also handle the case where tmin or tmax is NaN + // (result of 0 * Infinity). x !== x returns true if x is NaN + + if ( tymin > tmin || tmin !== tmin ) tmin = tymin; + + if ( tymax < tmax || tmax !== tmax ) tmax = tymax; + + if ( invdirz >= 0 ) { + + tzmin = ( box.min.z - origin.z ) * invdirz; + tzmax = ( box.max.z - origin.z ) * invdirz; + + } else { + + tzmin = ( box.max.z - origin.z ) * invdirz; + tzmax = ( box.min.z - origin.z ) * invdirz; + + } + + if ( ( tmin > tzmax ) || ( tzmin > tmax ) ) return null; + + if ( tzmin > tmin || tmin !== tmin ) tmin = tzmin; + + if ( tzmax < tmax || tmax !== tmax ) tmax = tzmax; + + //return point closest to the ray (positive side) + + if ( tmax < 0 ) return null; + + return this.at( tmin >= 0 ? tmin : tmax, target ); + + }, + + intersectsBox: ( function () { + + var v = new Vector3(); + + return function intersectsBox( box ) { + + return this.intersectBox( box, v ) !== null; + + }; + + } )(), + + intersectTriangle: function () { + + // Compute the offset origin, edges, and normal. + var diff = new Vector3(); + var edge1 = new Vector3(); + var edge2 = new Vector3(); + var normal = new Vector3(); + + return function intersectTriangle( a, b, c, backfaceCulling, target ) { + + // from http://www.geometrictools.com/GTEngine/Include/Mathematics/GteIntrRay3Triangle3.h + + edge1.subVectors( b, a ); + edge2.subVectors( c, a ); + normal.crossVectors( edge1, edge2 ); + + // Solve Q + t*D = b1*E1 + b2*E2 (Q = kDiff, D = ray direction, + // E1 = kEdge1, E2 = kEdge2, N = Cross(E1,E2)) by + // |Dot(D,N)|*b1 = sign(Dot(D,N))*Dot(D,Cross(Q,E2)) + // |Dot(D,N)|*b2 = sign(Dot(D,N))*Dot(D,Cross(E1,Q)) + // |Dot(D,N)|*t = -sign(Dot(D,N))*Dot(Q,N) + var DdN = this.direction.dot( normal ); + var sign; + + if ( DdN > 0 ) { + + if ( backfaceCulling ) return null; + sign = 1; + + } else if ( DdN < 0 ) { + + sign = - 1; + DdN = - DdN; + + } else { + + return null; + + } + + diff.subVectors( this.origin, a ); + var DdQxE2 = sign * this.direction.dot( edge2.crossVectors( diff, edge2 ) ); + + // b1 < 0, no intersection + if ( DdQxE2 < 0 ) { + + return null; + + } + + var DdE1xQ = sign * this.direction.dot( edge1.cross( diff ) ); + + // b2 < 0, no intersection + if ( DdE1xQ < 0 ) { + + return null; + + } + + // b1+b2 > 1, no intersection + if ( DdQxE2 + DdE1xQ > DdN ) { + + return null; + + } + + // Line intersects triangle, check if ray does. + var QdN = - sign * diff.dot( normal ); + + // t < 0, no intersection + if ( QdN < 0 ) { + + return null; + + } + + // Ray intersects triangle. + return this.at( QdN / DdN, target ); + + }; + + }(), + + applyMatrix4: function ( matrix4 ) { + + this.origin.applyMatrix4( matrix4 ); + this.direction.transformDirection( matrix4 ); + + return this; + + }, + + equals: function ( ray ) { + + return ray.origin.equals( this.origin ) && ray.direction.equals( this.direction ); + + } + + } ); + + /** + * @author bhouston / http://clara.io + */ + + function Line3( start, end ) { + + this.start = ( start !== undefined ) ? start : new Vector3(); + this.end = ( end !== undefined ) ? end : new Vector3(); + + } + + Object.assign( Line3.prototype, { + + set: function ( start, end ) { + + this.start.copy( start ); + this.end.copy( end ); + + return this; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( line ) { + + this.start.copy( line.start ); + this.end.copy( line.end ); + + return this; + + }, + + getCenter: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Line3: .getCenter() target is now required' ); + target = new Vector3(); + + } + + return target.addVectors( this.start, this.end ).multiplyScalar( 0.5 ); + + }, + + delta: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Line3: .delta() target is now required' ); + target = new Vector3(); + + } + + return target.subVectors( this.end, this.start ); + + }, + + distanceSq: function () { + + return this.start.distanceToSquared( this.end ); + + }, + + distance: function () { + + return this.start.distanceTo( this.end ); + + }, + + at: function ( t, target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Line3: .at() target is now required' ); + target = new Vector3(); + + } + + return this.delta( target ).multiplyScalar( t ).add( this.start ); + + }, + + closestPointToPointParameter: function () { + + var startP = new Vector3(); + var startEnd = new Vector3(); + + return function closestPointToPointParameter( point, clampToLine ) { + + startP.subVectors( point, this.start ); + startEnd.subVectors( this.end, this.start ); + + var startEnd2 = startEnd.dot( startEnd ); + var startEnd_startP = startEnd.dot( startP ); + + var t = startEnd_startP / startEnd2; + + if ( clampToLine ) { + + t = _Math.clamp( t, 0, 1 ); + + } + + return t; + + }; + + }(), + + closestPointToPoint: function ( point, clampToLine, target ) { + + var t = this.closestPointToPointParameter( point, clampToLine ); + + if ( target === undefined ) { + + console.warn( 'THREE.Line3: .closestPointToPoint() target is now required' ); + target = new Vector3(); + + } + + return this.delta( target ).multiplyScalar( t ).add( this.start ); + + }, + + applyMatrix4: function ( matrix ) { + + this.start.applyMatrix4( matrix ); + this.end.applyMatrix4( matrix ); + + return this; + + }, + + equals: function ( line ) { + + return line.start.equals( this.start ) && line.end.equals( this.end ); + + } + + } ); + + /** + * @author bhouston / http://clara.io + * @author mrdoob / http://mrdoob.com/ + */ + + function Triangle( a, b, c ) { + + this.a = ( a !== undefined ) ? a : new Vector3(); + this.b = ( b !== undefined ) ? b : new Vector3(); + this.c = ( c !== undefined ) ? c : new Vector3(); + + } + + Object.assign( Triangle, { + + getNormal: function () { + + var v0 = new Vector3(); + + return function getNormal( a, b, c, target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Triangle: .getNormal() target is now required' ); + target = new Vector3(); + + } + + target.subVectors( c, b ); + v0.subVectors( a, b ); + target.cross( v0 ); + + var targetLengthSq = target.lengthSq(); + if ( targetLengthSq > 0 ) { + + return target.multiplyScalar( 1 / Math.sqrt( targetLengthSq ) ); + + } + + return target.set( 0, 0, 0 ); + + }; + + }(), + + // static/instance method to calculate barycentric coordinates + // based on: http://www.blackpawn.com/texts/pointinpoly/default.html + getBarycoord: function () { + + var v0 = new Vector3(); + var v1 = new Vector3(); + var v2 = new Vector3(); + + return function getBarycoord( point, a, b, c, target ) { + + v0.subVectors( c, a ); + v1.subVectors( b, a ); + v2.subVectors( point, a ); + + var dot00 = v0.dot( v0 ); + var dot01 = v0.dot( v1 ); + var dot02 = v0.dot( v2 ); + var dot11 = v1.dot( v1 ); + var dot12 = v1.dot( v2 ); + + var denom = ( dot00 * dot11 - dot01 * dot01 ); + + if ( target === undefined ) { + + console.warn( 'THREE.Triangle: .getBarycoord() target is now required' ); + target = new Vector3(); + + } + + // collinear or singular triangle + if ( denom === 0 ) { + + // arbitrary location outside of triangle? + // not sure if this is the best idea, maybe should be returning undefined + return target.set( - 2, - 1, - 1 ); + + } + + var invDenom = 1 / denom; + var u = ( dot11 * dot02 - dot01 * dot12 ) * invDenom; + var v = ( dot00 * dot12 - dot01 * dot02 ) * invDenom; + + // barycentric coordinates must always sum to 1 + return target.set( 1 - u - v, v, u ); + + }; + + }(), + + containsPoint: function () { + + var v1 = new Vector3(); + + return function containsPoint( point, a, b, c ) { + + Triangle.getBarycoord( point, a, b, c, v1 ); + + return ( v1.x >= 0 ) && ( v1.y >= 0 ) && ( ( v1.x + v1.y ) <= 1 ); + + }; + + }() + + } ); + + Object.assign( Triangle.prototype, { + + set: function ( a, b, c ) { + + this.a.copy( a ); + this.b.copy( b ); + this.c.copy( c ); + + return this; + + }, + + setFromPointsAndIndices: function ( points, i0, i1, i2 ) { + + this.a.copy( points[ i0 ] ); + this.b.copy( points[ i1 ] ); + this.c.copy( points[ i2 ] ); + + return this; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( triangle ) { + + this.a.copy( triangle.a ); + this.b.copy( triangle.b ); + this.c.copy( triangle.c ); + + return this; + + }, + + getArea: function () { + + var v0 = new Vector3(); + var v1 = new Vector3(); + + return function getArea() { + + v0.subVectors( this.c, this.b ); + v1.subVectors( this.a, this.b ); + + return v0.cross( v1 ).length() * 0.5; + + }; + + }(), + + getMidpoint: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Triangle: .getMidpoint() target is now required' ); + target = new Vector3(); + + } + + return target.addVectors( this.a, this.b ).add( this.c ).multiplyScalar( 1 / 3 ); + + }, + + getNormal: function ( target ) { + + return Triangle.getNormal( this.a, this.b, this.c, target ); + + }, + + getPlane: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Triangle: .getPlane() target is now required' ); + target = new Vector3(); + + } + + return target.setFromCoplanarPoints( this.a, this.b, this.c ); + + }, + + getBarycoord: function ( point, target ) { + + return Triangle.getBarycoord( point, this.a, this.b, this.c, target ); + + }, + + containsPoint: function ( point ) { + + return Triangle.containsPoint( point, this.a, this.b, this.c ); + + }, + + intersectsBox: function ( box ) { + + return box.intersectsTriangle( this ); + + }, + + closestPointToPoint: function () { + + var plane = new Plane(); + var edgeList = [ new Line3(), new Line3(), new Line3() ]; + var projectedPoint = new Vector3(); + var closestPoint = new Vector3(); + + return function closestPointToPoint( point, target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Triangle: .closestPointToPoint() target is now required' ); + target = new Vector3(); + + } + + var minDistance = Infinity; + + // project the point onto the plane of the triangle + + plane.setFromCoplanarPoints( this.a, this.b, this.c ); + plane.projectPoint( point, projectedPoint ); + + // check if the projection lies within the triangle + + if ( this.containsPoint( projectedPoint ) === true ) { + + // if so, this is the closest point + + target.copy( projectedPoint ); + + } else { + + // if not, the point falls outside the triangle. the target is the closest point to the triangle's edges or vertices + + edgeList[ 0 ].set( this.a, this.b ); + edgeList[ 1 ].set( this.b, this.c ); + edgeList[ 2 ].set( this.c, this.a ); + + for ( var i = 0; i < edgeList.length; i ++ ) { + + edgeList[ i ].closestPointToPoint( projectedPoint, true, closestPoint ); + + var distance = projectedPoint.distanceToSquared( closestPoint ); + + if ( distance < minDistance ) { + + minDistance = distance; + + target.copy( closestPoint ); + + } + + } + + } + + return target; + + }; + + }(), + + equals: function ( triangle ) { + + return triangle.a.equals( this.a ) && triangle.b.equals( this.b ) && triangle.c.equals( this.c ); + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + * @author mikael emtinger / http://gomo.se/ + * @author jonobr1 / http://jonobr1.com/ + */ + + function Mesh( geometry, material ) { + + Object3D.call( this ); + + this.type = 'Mesh'; + + this.geometry = geometry !== undefined ? geometry : new BufferGeometry(); + this.material = material !== undefined ? material : new MeshBasicMaterial( { color: Math.random() * 0xffffff } ); + + this.drawMode = TrianglesDrawMode; + + this.updateMorphTargets(); + + } + + Mesh.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: Mesh, + + isMesh: true, + + setDrawMode: function ( value ) { + + this.drawMode = value; + + }, + + copy: function ( source ) { + + Object3D.prototype.copy.call( this, source ); + + this.drawMode = source.drawMode; + + if ( source.morphTargetInfluences !== undefined ) { + + this.morphTargetInfluences = source.morphTargetInfluences.slice(); + + } + + if ( source.morphTargetDictionary !== undefined ) { + + this.morphTargetDictionary = Object.assign( {}, source.morphTargetDictionary ); + + } + + return this; + + }, + + updateMorphTargets: function () { + + var geometry = this.geometry; + var m, ml, name; + + if ( geometry.isBufferGeometry ) { + + var morphAttributes = geometry.morphAttributes; + var keys = Object.keys( morphAttributes ); + + if ( keys.length > 0 ) { + + var morphAttribute = morphAttributes[ keys[ 0 ] ]; + + if ( morphAttribute !== undefined ) { + + this.morphTargetInfluences = []; + this.morphTargetDictionary = {}; + + for ( m = 0, ml = morphAttribute.length; m < ml; m ++ ) { + + name = morphAttribute[ m ].name || String( m ); + + this.morphTargetInfluences.push( 0 ); + this.morphTargetDictionary[ name ] = m; + + } + + } + + } + + } else { + + var morphTargets = geometry.morphTargets; + + if ( morphTargets !== undefined && morphTargets.length > 0 ) { + + this.morphTargetInfluences = []; + this.morphTargetDictionary = {}; + + for ( m = 0, ml = morphTargets.length; m < ml; m ++ ) { + + name = morphTargets[ m ].name || String( m ); + + this.morphTargetInfluences.push( 0 ); + this.morphTargetDictionary[ name ] = m; + + } + + } + + } + + }, + + raycast: ( function () { + + var inverseMatrix = new Matrix4(); + var ray = new Ray(); + var sphere = new Sphere(); + + var vA = new Vector3(); + var vB = new Vector3(); + var vC = new Vector3(); + + var tempA = new Vector3(); + var tempB = new Vector3(); + var tempC = new Vector3(); + + var uvA = new Vector2(); + var uvB = new Vector2(); + var uvC = new Vector2(); + + var barycoord = new Vector3(); + + var intersectionPoint = new Vector3(); + var intersectionPointWorld = new Vector3(); + + function uvIntersection( point, p1, p2, p3, uv1, uv2, uv3 ) { + + Triangle.getBarycoord( point, p1, p2, p3, barycoord ); + + uv1.multiplyScalar( barycoord.x ); + uv2.multiplyScalar( barycoord.y ); + uv3.multiplyScalar( barycoord.z ); + + uv1.add( uv2 ).add( uv3 ); + + return uv1.clone(); + + } + + function checkIntersection( object, material, raycaster, ray, pA, pB, pC, point ) { + + var intersect; + + if ( material.side === BackSide ) { + + intersect = ray.intersectTriangle( pC, pB, pA, true, point ); + + } else { + + intersect = ray.intersectTriangle( pA, pB, pC, material.side !== DoubleSide, point ); + + } + + if ( intersect === null ) return null; + + intersectionPointWorld.copy( point ); + intersectionPointWorld.applyMatrix4( object.matrixWorld ); + + var distance = raycaster.ray.origin.distanceTo( intersectionPointWorld ); + + if ( distance < raycaster.near || distance > raycaster.far ) return null; + + return { + distance: distance, + point: intersectionPointWorld.clone(), + object: object + }; + + } + + function checkBufferGeometryIntersection( object, raycaster, ray, position, uv, a, b, c ) { + + vA.fromBufferAttribute( position, a ); + vB.fromBufferAttribute( position, b ); + vC.fromBufferAttribute( position, c ); + + var intersection = checkIntersection( object, object.material, raycaster, ray, vA, vB, vC, intersectionPoint ); + + if ( intersection ) { + + if ( uv ) { + + uvA.fromBufferAttribute( uv, a ); + uvB.fromBufferAttribute( uv, b ); + uvC.fromBufferAttribute( uv, c ); + + intersection.uv = uvIntersection( intersectionPoint, vA, vB, vC, uvA, uvB, uvC ); + + } + + var face = new Face3( a, b, c ); + Triangle.getNormal( vA, vB, vC, face.normal ); + + intersection.face = face; + + } + + return intersection; + + } + + return function raycast( raycaster, intersects ) { + + var geometry = this.geometry; + var material = this.material; + var matrixWorld = this.matrixWorld; + + if ( material === undefined ) return; + + // Checking boundingSphere distance to ray + + if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere(); + + sphere.copy( geometry.boundingSphere ); + sphere.applyMatrix4( matrixWorld ); + + if ( raycaster.ray.intersectsSphere( sphere ) === false ) return; + + // + + inverseMatrix.getInverse( matrixWorld ); + ray.copy( raycaster.ray ).applyMatrix4( inverseMatrix ); + + // Check boundingBox before continuing + + if ( geometry.boundingBox !== null ) { + + if ( ray.intersectsBox( geometry.boundingBox ) === false ) return; + + } + + var intersection; + + if ( geometry.isBufferGeometry ) { + + var a, b, c; + var index = geometry.index; + var position = geometry.attributes.position; + var uv = geometry.attributes.uv; + var i, l; + + if ( index !== null ) { + + // indexed buffer geometry + + for ( i = 0, l = index.count; i < l; i += 3 ) { + + a = index.getX( i ); + b = index.getX( i + 1 ); + c = index.getX( i + 2 ); + + intersection = checkBufferGeometryIntersection( this, raycaster, ray, position, uv, a, b, c ); + + if ( intersection ) { + + intersection.faceIndex = Math.floor( i / 3 ); // triangle number in indexed buffer semantics + intersects.push( intersection ); + + } + + } + + } else if ( position !== undefined ) { + + // non-indexed buffer geometry + + for ( i = 0, l = position.count; i < l; i += 3 ) { + + a = i; + b = i + 1; + c = i + 2; + + intersection = checkBufferGeometryIntersection( this, raycaster, ray, position, uv, a, b, c ); + + if ( intersection ) { + + intersection.faceIndex = Math.floor( i / 3 ); // triangle number in non-indexed buffer semantics + intersects.push( intersection ); + + } + + } + + } + + } else if ( geometry.isGeometry ) { + + var fvA, fvB, fvC; + var isMultiMaterial = Array.isArray( material ); + + var vertices = geometry.vertices; + var faces = geometry.faces; + var uvs; + + var faceVertexUvs = geometry.faceVertexUvs[ 0 ]; + if ( faceVertexUvs.length > 0 ) uvs = faceVertexUvs; + + for ( var f = 0, fl = faces.length; f < fl; f ++ ) { + + var face = faces[ f ]; + var faceMaterial = isMultiMaterial ? material[ face.materialIndex ] : material; + + if ( faceMaterial === undefined ) continue; + + fvA = vertices[ face.a ]; + fvB = vertices[ face.b ]; + fvC = vertices[ face.c ]; + + if ( faceMaterial.morphTargets === true ) { + + var morphTargets = geometry.morphTargets; + var morphInfluences = this.morphTargetInfluences; + + vA.set( 0, 0, 0 ); + vB.set( 0, 0, 0 ); + vC.set( 0, 0, 0 ); + + for ( var t = 0, tl = morphTargets.length; t < tl; t ++ ) { + + var influence = morphInfluences[ t ]; + + if ( influence === 0 ) continue; + + var targets = morphTargets[ t ].vertices; + + vA.addScaledVector( tempA.subVectors( targets[ face.a ], fvA ), influence ); + vB.addScaledVector( tempB.subVectors( targets[ face.b ], fvB ), influence ); + vC.addScaledVector( tempC.subVectors( targets[ face.c ], fvC ), influence ); + + } + + vA.add( fvA ); + vB.add( fvB ); + vC.add( fvC ); + + fvA = vA; + fvB = vB; + fvC = vC; + + } + + intersection = checkIntersection( this, faceMaterial, raycaster, ray, fvA, fvB, fvC, intersectionPoint ); + + if ( intersection ) { + + if ( uvs && uvs[ f ] ) { + + var uvs_f = uvs[ f ]; + uvA.copy( uvs_f[ 0 ] ); + uvB.copy( uvs_f[ 1 ] ); + uvC.copy( uvs_f[ 2 ] ); + + intersection.uv = uvIntersection( intersectionPoint, fvA, fvB, fvC, uvA, uvB, uvC ); + + } + + intersection.face = face; + intersection.faceIndex = f; + intersects.push( intersection ); + + } + + } + + } + + }; + + }() ), + + clone: function () { + + return new this.constructor( this.geometry, this.material ).copy( this ); + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLBackground( renderer, state, objects, premultipliedAlpha ) { + + var clearColor = new Color( 0x000000 ); + var clearAlpha = 0; + + var planeCamera, planeMesh; + var boxMesh; + + function render( renderList, scene, camera, forceClear ) { + + var background = scene.background; + + if ( background === null ) { + + setClear( clearColor, clearAlpha ); + + } else if ( background && background.isColor ) { + + setClear( background, 1 ); + forceClear = true; + + } + + if ( renderer.autoClear || forceClear ) { + + renderer.clear( renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil ); + + } + + if ( background && background.isCubeTexture ) { + + if ( boxMesh === undefined ) { + + boxMesh = new Mesh( + new BoxBufferGeometry( 1, 1, 1 ), + new ShaderMaterial( { + uniforms: ShaderLib.cube.uniforms, + vertexShader: ShaderLib.cube.vertexShader, + fragmentShader: ShaderLib.cube.fragmentShader, + side: BackSide, + depthTest: true, + depthWrite: false, + fog: false + } ) + ); + + boxMesh.geometry.removeAttribute( 'normal' ); + boxMesh.geometry.removeAttribute( 'uv' ); + + boxMesh.onBeforeRender = function ( renderer, scene, camera ) { + + this.matrixWorld.copyPosition( camera.matrixWorld ); + + }; + + objects.update( boxMesh ); + + } + + boxMesh.material.uniforms.tCube.value = background; + + renderList.push( boxMesh, boxMesh.geometry, boxMesh.material, 0, null ); + + } else if ( background && background.isTexture ) { + + if ( planeCamera === undefined ) { + + planeCamera = new OrthographicCamera( - 1, 1, 1, - 1, 0, 1 ); + + planeMesh = new Mesh( + new PlaneBufferGeometry( 2, 2 ), + new MeshBasicMaterial( { depthTest: false, depthWrite: false, fog: false } ) + ); + + objects.update( planeMesh ); + + } + + planeMesh.material.map = background; + + // TODO Push this to renderList + + renderer.renderBufferDirect( planeCamera, null, planeMesh.geometry, planeMesh.material, planeMesh, null ); + + } + + } + + function setClear( color, alpha ) { + + state.buffers.color.setClear( color.r, color.g, color.b, alpha, premultipliedAlpha ); + + } + + return { + + getClearColor: function () { + + return clearColor; + + }, + setClearColor: function ( color, alpha ) { + + clearColor.set( color ); + clearAlpha = alpha !== undefined ? alpha : 1; + setClear( clearColor, clearAlpha ); + + }, + getClearAlpha: function () { + + return clearAlpha; + + }, + setClearAlpha: function ( alpha ) { + + clearAlpha = alpha; + setClear( clearColor, clearAlpha ); + + }, + render: render + + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLBufferRenderer( gl, extensions, info ) { + + var mode; + + function setMode( value ) { + + mode = value; + + } + + function render( start, count ) { + + gl.drawArrays( mode, start, count ); + + info.update( count, mode ); + + } + + function renderInstances( geometry, start, count ) { + + var extension = extensions.get( 'ANGLE_instanced_arrays' ); + + if ( extension === null ) { + + console.error( 'THREE.WebGLBufferRenderer: using THREE.InstancedBufferGeometry but hardware does not support extension ANGLE_instanced_arrays.' ); + return; + + } + + extension.drawArraysInstancedANGLE( mode, start, count, geometry.maxInstancedCount ); + + info.update( count, mode, geometry.maxInstancedCount ); + + } + + // + + this.setMode = setMode; + this.render = render; + this.renderInstances = renderInstances; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLCapabilities( gl, extensions, parameters ) { + + var maxAnisotropy; + + function getMaxAnisotropy() { + + if ( maxAnisotropy !== undefined ) return maxAnisotropy; + + var extension = extensions.get( 'EXT_texture_filter_anisotropic' ); + + if ( extension !== null ) { + + maxAnisotropy = gl.getParameter( extension.MAX_TEXTURE_MAX_ANISOTROPY_EXT ); + + } else { + + maxAnisotropy = 0; + + } + + return maxAnisotropy; + + } + + function getMaxPrecision( precision ) { + + if ( precision === 'highp' ) { + + if ( gl.getShaderPrecisionFormat( gl.VERTEX_SHADER, gl.HIGH_FLOAT ).precision > 0 && + gl.getShaderPrecisionFormat( gl.FRAGMENT_SHADER, gl.HIGH_FLOAT ).precision > 0 ) { + + return 'highp'; + + } + + precision = 'mediump'; + + } + + if ( precision === 'mediump' ) { + + if ( gl.getShaderPrecisionFormat( gl.VERTEX_SHADER, gl.MEDIUM_FLOAT ).precision > 0 && + gl.getShaderPrecisionFormat( gl.FRAGMENT_SHADER, gl.MEDIUM_FLOAT ).precision > 0 ) { + + return 'mediump'; + + } + + } + + return 'lowp'; + + } + + var precision = parameters.precision !== undefined ? parameters.precision : 'highp'; + var maxPrecision = getMaxPrecision( precision ); + + if ( maxPrecision !== precision ) { + + console.warn( 'THREE.WebGLRenderer:', precision, 'not supported, using', maxPrecision, 'instead.' ); + precision = maxPrecision; + + } + + var logarithmicDepthBuffer = parameters.logarithmicDepthBuffer === true; + + var maxTextures = gl.getParameter( gl.MAX_TEXTURE_IMAGE_UNITS ); + var maxVertexTextures = gl.getParameter( gl.MAX_VERTEX_TEXTURE_IMAGE_UNITS ); + var maxTextureSize = gl.getParameter( gl.MAX_TEXTURE_SIZE ); + var maxCubemapSize = gl.getParameter( gl.MAX_CUBE_MAP_TEXTURE_SIZE ); + + var maxAttributes = gl.getParameter( gl.MAX_VERTEX_ATTRIBS ); + var maxVertexUniforms = gl.getParameter( gl.MAX_VERTEX_UNIFORM_VECTORS ); + var maxVaryings = gl.getParameter( gl.MAX_VARYING_VECTORS ); + var maxFragmentUniforms = gl.getParameter( gl.MAX_FRAGMENT_UNIFORM_VECTORS ); + + var vertexTextures = maxVertexTextures > 0; + var floatFragmentTextures = !! extensions.get( 'OES_texture_float' ); + var floatVertexTextures = vertexTextures && floatFragmentTextures; + + return { + + getMaxAnisotropy: getMaxAnisotropy, + getMaxPrecision: getMaxPrecision, + + precision: precision, + logarithmicDepthBuffer: logarithmicDepthBuffer, + + maxTextures: maxTextures, + maxVertexTextures: maxVertexTextures, + maxTextureSize: maxTextureSize, + maxCubemapSize: maxCubemapSize, + + maxAttributes: maxAttributes, + maxVertexUniforms: maxVertexUniforms, + maxVaryings: maxVaryings, + maxFragmentUniforms: maxFragmentUniforms, + + vertexTextures: vertexTextures, + floatFragmentTextures: floatFragmentTextures, + floatVertexTextures: floatVertexTextures + + }; + + } + + /** + * @author tschw + */ + + function WebGLClipping() { + + var scope = this, + + globalState = null, + numGlobalPlanes = 0, + localClippingEnabled = false, + renderingShadows = false, + + plane = new Plane(), + viewNormalMatrix = new Matrix3(), + + uniform = { value: null, needsUpdate: false }; + + this.uniform = uniform; + this.numPlanes = 0; + this.numIntersection = 0; + + this.init = function ( planes, enableLocalClipping, camera ) { + + var enabled = + planes.length !== 0 || + enableLocalClipping || + // enable state of previous frame - the clipping code has to + // run another frame in order to reset the state: + numGlobalPlanes !== 0 || + localClippingEnabled; + + localClippingEnabled = enableLocalClipping; + + globalState = projectPlanes( planes, camera, 0 ); + numGlobalPlanes = planes.length; + + return enabled; + + }; + + this.beginShadows = function () { + + renderingShadows = true; + projectPlanes( null ); + + }; + + this.endShadows = function () { + + renderingShadows = false; + resetGlobalState(); + + }; + + this.setState = function ( planes, clipIntersection, clipShadows, camera, cache, fromCache ) { + + if ( ! localClippingEnabled || planes === null || planes.length === 0 || renderingShadows && ! clipShadows ) { + + // there's no local clipping + + if ( renderingShadows ) { + + // there's no global clipping + + projectPlanes( null ); + + } else { + + resetGlobalState(); + + } + + } else { + + var nGlobal = renderingShadows ? 0 : numGlobalPlanes, + lGlobal = nGlobal * 4, + + dstArray = cache.clippingState || null; + + uniform.value = dstArray; // ensure unique state + + dstArray = projectPlanes( planes, camera, lGlobal, fromCache ); + + for ( var i = 0; i !== lGlobal; ++ i ) { + + dstArray[ i ] = globalState[ i ]; + + } + + cache.clippingState = dstArray; + this.numIntersection = clipIntersection ? this.numPlanes : 0; + this.numPlanes += nGlobal; + + } + + + }; + + function resetGlobalState() { + + if ( uniform.value !== globalState ) { + + uniform.value = globalState; + uniform.needsUpdate = numGlobalPlanes > 0; + + } + + scope.numPlanes = numGlobalPlanes; + scope.numIntersection = 0; + + } + + function projectPlanes( planes, camera, dstOffset, skipTransform ) { + + var nPlanes = planes !== null ? planes.length : 0, + dstArray = null; + + if ( nPlanes !== 0 ) { + + dstArray = uniform.value; + + if ( skipTransform !== true || dstArray === null ) { + + var flatSize = dstOffset + nPlanes * 4, + viewMatrix = camera.matrixWorldInverse; + + viewNormalMatrix.getNormalMatrix( viewMatrix ); + + if ( dstArray === null || dstArray.length < flatSize ) { + + dstArray = new Float32Array( flatSize ); + + } + + for ( var i = 0, i4 = dstOffset; i !== nPlanes; ++ i, i4 += 4 ) { + + plane.copy( planes[ i ] ).applyMatrix4( viewMatrix, viewNormalMatrix ); + + plane.normal.toArray( dstArray, i4 ); + dstArray[ i4 + 3 ] = plane.constant; + + } + + } + + uniform.value = dstArray; + uniform.needsUpdate = true; + + } + + scope.numPlanes = nPlanes; + + return dstArray; + + } + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLExtensions( gl ) { + + var extensions = {}; + + return { + + get: function ( name ) { + + if ( extensions[ name ] !== undefined ) { + + return extensions[ name ]; + + } + + var extension; + + switch ( name ) { + + case 'WEBGL_depth_texture': + extension = gl.getExtension( 'WEBGL_depth_texture' ) || gl.getExtension( 'MOZ_WEBGL_depth_texture' ) || gl.getExtension( 'WEBKIT_WEBGL_depth_texture' ); + break; + + case 'EXT_texture_filter_anisotropic': + extension = gl.getExtension( 'EXT_texture_filter_anisotropic' ) || gl.getExtension( 'MOZ_EXT_texture_filter_anisotropic' ) || gl.getExtension( 'WEBKIT_EXT_texture_filter_anisotropic' ); + break; + + case 'WEBGL_compressed_texture_s3tc': + extension = gl.getExtension( 'WEBGL_compressed_texture_s3tc' ) || gl.getExtension( 'MOZ_WEBGL_compressed_texture_s3tc' ) || gl.getExtension( 'WEBKIT_WEBGL_compressed_texture_s3tc' ); + break; + + case 'WEBGL_compressed_texture_pvrtc': + extension = gl.getExtension( 'WEBGL_compressed_texture_pvrtc' ) || gl.getExtension( 'WEBKIT_WEBGL_compressed_texture_pvrtc' ); + break; + + default: + extension = gl.getExtension( name ); + + } + + if ( extension === null ) { + + console.warn( 'THREE.WebGLRenderer: ' + name + ' extension not supported.' ); + + } + + extensions[ name ] = extension; + + return extension; + + } + + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLGeometries( gl, attributes, info ) { + + var geometries = {}; + var wireframeAttributes = {}; + + function onGeometryDispose( event ) { + + var geometry = event.target; + var buffergeometry = geometries[ geometry.id ]; + + if ( buffergeometry.index !== null ) { + + attributes.remove( buffergeometry.index ); + + } + + for ( var name in buffergeometry.attributes ) { + + attributes.remove( buffergeometry.attributes[ name ] ); + + } + + geometry.removeEventListener( 'dispose', onGeometryDispose ); + + delete geometries[ geometry.id ]; + + // TODO Remove duplicate code + + var attribute = wireframeAttributes[ geometry.id ]; + + if ( attribute ) { + + attributes.remove( attribute ); + delete wireframeAttributes[ geometry.id ]; + + } + + attribute = wireframeAttributes[ buffergeometry.id ]; + + if ( attribute ) { + + attributes.remove( attribute ); + delete wireframeAttributes[ buffergeometry.id ]; + + } + + // + + info.memory.geometries --; + + } + + function get( object, geometry ) { + + var buffergeometry = geometries[ geometry.id ]; + + if ( buffergeometry ) return buffergeometry; + + geometry.addEventListener( 'dispose', onGeometryDispose ); + + if ( geometry.isBufferGeometry ) { + + buffergeometry = geometry; + + } else if ( geometry.isGeometry ) { + + if ( geometry._bufferGeometry === undefined ) { + + geometry._bufferGeometry = new BufferGeometry().setFromObject( object ); + + } + + buffergeometry = geometry._bufferGeometry; + + } + + geometries[ geometry.id ] = buffergeometry; + + info.memory.geometries ++; + + return buffergeometry; + + } + + function update( geometry ) { + + var index = geometry.index; + var geometryAttributes = geometry.attributes; + + if ( index !== null ) { + + attributes.update( index, gl.ELEMENT_ARRAY_BUFFER ); + + } + + for ( var name in geometryAttributes ) { + + attributes.update( geometryAttributes[ name ], gl.ARRAY_BUFFER ); + + } + + // morph targets + + var morphAttributes = geometry.morphAttributes; + + for ( var name in morphAttributes ) { + + var array = morphAttributes[ name ]; + + for ( var i = 0, l = array.length; i < l; i ++ ) { + + attributes.update( array[ i ], gl.ARRAY_BUFFER ); + + } + + } + + } + + function getWireframeAttribute( geometry ) { + + var attribute = wireframeAttributes[ geometry.id ]; + + if ( attribute ) return attribute; + + var indices = []; + + var geometryIndex = geometry.index; + var geometryAttributes = geometry.attributes; + + // console.time( 'wireframe' ); + + if ( geometryIndex !== null ) { + + var array = geometryIndex.array; + + for ( var i = 0, l = array.length; i < l; i += 3 ) { + + var a = array[ i + 0 ]; + var b = array[ i + 1 ]; + var c = array[ i + 2 ]; + + indices.push( a, b, b, c, c, a ); + + } + + } else { + + var array = geometryAttributes.position.array; + + for ( var i = 0, l = ( array.length / 3 ) - 1; i < l; i += 3 ) { + + var a = i + 0; + var b = i + 1; + var c = i + 2; + + indices.push( a, b, b, c, c, a ); + + } + + } + + // console.timeEnd( 'wireframe' ); + + attribute = new ( arrayMax( indices ) > 65535 ? Uint32BufferAttribute : Uint16BufferAttribute )( indices, 1 ); + + attributes.update( attribute, gl.ELEMENT_ARRAY_BUFFER ); + + wireframeAttributes[ geometry.id ] = attribute; + + return attribute; + + } + + return { + + get: get, + update: update, + + getWireframeAttribute: getWireframeAttribute + + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLIndexedBufferRenderer( gl, extensions, info ) { + + var mode; + + function setMode( value ) { + + mode = value; + + } + + var type, bytesPerElement; + + function setIndex( value ) { + + type = value.type; + bytesPerElement = value.bytesPerElement; + + } + + function render( start, count ) { + + gl.drawElements( mode, count, type, start * bytesPerElement ); + + info.update( count, mode ); + + } + + function renderInstances( geometry, start, count ) { + + var extension = extensions.get( 'ANGLE_instanced_arrays' ); + + if ( extension === null ) { + + console.error( 'THREE.WebGLIndexedBufferRenderer: using THREE.InstancedBufferGeometry but hardware does not support extension ANGLE_instanced_arrays.' ); + return; + + } + + extension.drawElementsInstancedANGLE( mode, count, type, start * bytesPerElement, geometry.maxInstancedCount ); + + info.update( count, mode, geometry.maxInstancedCount ); + + } + + // + + this.setMode = setMode; + this.setIndex = setIndex; + this.render = render; + this.renderInstances = renderInstances; + + } + + /** + * @author Mugen87 / https://github.com/Mugen87 + */ + + function WebGLInfo( gl ) { + + var memory = { + geometries: 0, + textures: 0 + }; + + var render = { + frame: 0, + calls: 0, + triangles: 0, + points: 0, + lines: 0 + }; + + function update( count, mode, instanceCount ) { + + instanceCount = instanceCount || 1; + + render.calls ++; + + switch ( mode ) { + + case gl.TRIANGLES: + render.triangles += instanceCount * ( count / 3 ); + break; + + case gl.TRIANGLE_STRIP: + case gl.TRIANGLE_FAN: + render.triangles += instanceCount * ( count - 2 ); + break; + + case gl.LINES: + render.lines += instanceCount * ( count / 2 ); + break; + + case gl.LINE_STRIP: + render.lines += instanceCount * ( count - 1 ); + break; + + case gl.LINE_LOOP: + render.lines += instanceCount * count; + break; + + case gl.POINTS: + render.points += instanceCount * count; + break; + + default: + console.error( 'THREE.WebGLInfo: Unknown draw mode:', mode ); + break; + + } + + } + + function reset() { + + render.frame ++; + render.calls = 0; + render.triangles = 0; + render.points = 0; + render.lines = 0; + + } + + return { + memory: memory, + render: render, + programs: null, + autoReset: true, + reset: reset, + update: update + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function absNumericalSort( a, b ) { + + return Math.abs( b[ 1 ] ) - Math.abs( a[ 1 ] ); + + } + + function WebGLMorphtargets( gl ) { + + var influencesList = {}; + var morphInfluences = new Float32Array( 8 ); + + function update( object, geometry, material, program ) { + + var objectInfluences = object.morphTargetInfluences; + + var length = objectInfluences.length; + + var influences = influencesList[ geometry.id ]; + + if ( influences === undefined ) { + + // initialise list + + influences = []; + + for ( var i = 0; i < length; i ++ ) { + + influences[ i ] = [ i, 0 ]; + + } + + influencesList[ geometry.id ] = influences; + + } + + var morphTargets = material.morphTargets && geometry.morphAttributes.position; + var morphNormals = material.morphNormals && geometry.morphAttributes.normal; + + // Remove current morphAttributes + + for ( var i = 0; i < length; i ++ ) { + + var influence = influences[ i ]; + + if ( influence[ 1 ] !== 0 ) { + + if ( morphTargets ) geometry.removeAttribute( 'morphTarget' + i ); + if ( morphNormals ) geometry.removeAttribute( 'morphNormal' + i ); + + } + + } + + // Collect influences + + for ( var i = 0; i < length; i ++ ) { + + var influence = influences[ i ]; + + influence[ 0 ] = i; + influence[ 1 ] = objectInfluences[ i ]; + + } + + influences.sort( absNumericalSort ); + + // Add morphAttributes + + for ( var i = 0; i < 8; i ++ ) { + + var influence = influences[ i ]; + + if ( influence ) { + + var index = influence[ 0 ]; + var value = influence[ 1 ]; + + if ( value ) { + + if ( morphTargets ) geometry.addAttribute( 'morphTarget' + i, morphTargets[ index ] ); + if ( morphNormals ) geometry.addAttribute( 'morphNormal' + i, morphNormals[ index ] ); + + morphInfluences[ i ] = value; + continue; + + } + + } + + morphInfluences[ i ] = 0; + + } + + program.getUniforms().setValue( gl, 'morphTargetInfluences', morphInfluences ); + + } + + return { + + update: update + + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLObjects( geometries, info ) { + + var updateList = {}; + + function update( object ) { + + var frame = info.render.frame; + + var geometry = object.geometry; + var buffergeometry = geometries.get( object, geometry ); + + // Update once per frame + + if ( updateList[ buffergeometry.id ] !== frame ) { + + if ( geometry.isGeometry ) { + + buffergeometry.updateFromObject( object ); + + } + + geometries.update( buffergeometry ); + + updateList[ buffergeometry.id ] = frame; + + } + + return buffergeometry; + + } + + function dispose() { + + updateList = {}; + + } + + return { + + update: update, + dispose: dispose + + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function CubeTexture( images, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy, encoding ) { + + images = images !== undefined ? images : []; + mapping = mapping !== undefined ? mapping : CubeReflectionMapping; + + Texture.call( this, images, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy, encoding ); + + this.flipY = false; + + } + + CubeTexture.prototype = Object.create( Texture.prototype ); + CubeTexture.prototype.constructor = CubeTexture; + + CubeTexture.prototype.isCubeTexture = true; + + Object.defineProperty( CubeTexture.prototype, 'images', { + + get: function () { + + return this.image; + + }, + + set: function ( value ) { + + this.image = value; + + } + + } ); + + /** + * @author tschw + * + * Uniforms of a program. + * Those form a tree structure with a special top-level container for the root, + * which you get by calling 'new WebGLUniforms( gl, program, renderer )'. + * + * + * Properties of inner nodes including the top-level container: + * + * .seq - array of nested uniforms + * .map - nested uniforms by name + * + * + * Methods of all nodes except the top-level container: + * + * .setValue( gl, value, [renderer] ) + * + * uploads a uniform value(s) + * the 'renderer' parameter is needed for sampler uniforms + * + * + * Static methods of the top-level container (renderer factorizations): + * + * .upload( gl, seq, values, renderer ) + * + * sets uniforms in 'seq' to 'values[id].value' + * + * .seqWithValue( seq, values ) : filteredSeq + * + * filters 'seq' entries with corresponding entry in values + * + * + * Methods of the top-level container (renderer factorizations): + * + * .setValue( gl, name, value ) + * + * sets uniform with name 'name' to 'value' + * + * .set( gl, obj, prop ) + * + * sets uniform from object and property with same name than uniform + * + * .setOptional( gl, obj, prop ) + * + * like .set for an optional property of the object + * + */ + + var emptyTexture = new Texture(); + var emptyCubeTexture = new CubeTexture(); + + // --- Base for inner nodes (including the root) --- + + function UniformContainer() { + + this.seq = []; + this.map = {}; + + } + + // --- Utilities --- + + // Array Caches (provide typed arrays for temporary by size) + + var arrayCacheF32 = []; + var arrayCacheI32 = []; + + // Float32Array caches used for uploading Matrix uniforms + + var mat4array = new Float32Array( 16 ); + var mat3array = new Float32Array( 9 ); + var mat2array = new Float32Array( 4 ); + + // Flattening for arrays of vectors and matrices + + function flatten( array, nBlocks, blockSize ) { + + var firstElem = array[ 0 ]; + + if ( firstElem <= 0 || firstElem > 0 ) return array; + // unoptimized: ! isNaN( firstElem ) + // see http://jacksondunstan.com/articles/983 + + var n = nBlocks * blockSize, + r = arrayCacheF32[ n ]; + + if ( r === undefined ) { + + r = new Float32Array( n ); + arrayCacheF32[ n ] = r; + + } + + if ( nBlocks !== 0 ) { + + firstElem.toArray( r, 0 ); + + for ( var i = 1, offset = 0; i !== nBlocks; ++ i ) { + + offset += blockSize; + array[ i ].toArray( r, offset ); + + } + + } + + return r; + + } + + function arraysEqual( a, b ) { + + if ( a.length !== b.length ) return false; + + for ( var i = 0, l = a.length; i < l; i ++ ) { + + if ( a[ i ] !== b[ i ] ) return false; + + } + + return true; + + } + + function copyArray( a, b ) { + + for ( var i = 0, l = b.length; i < l; i ++ ) { + + a[ i ] = b[ i ]; + + } + + } + + // Texture unit allocation + + function allocTexUnits( renderer, n ) { + + var r = arrayCacheI32[ n ]; + + if ( r === undefined ) { + + r = new Int32Array( n ); + arrayCacheI32[ n ] = r; + + } + + for ( var i = 0; i !== n; ++ i ) + r[ i ] = renderer.allocTextureUnit(); + + return r; + + } + + // --- Setters --- + + // Note: Defining these methods externally, because they come in a bunch + // and this way their names minify. + + // Single scalar + + function setValue1f( gl, v ) { + + var cache = this.cache; + + if ( cache[ 0 ] === v ) return; + + gl.uniform1f( this.addr, v ); + + cache[ 0 ] = v; + + } + + function setValue1i( gl, v ) { + + var cache = this.cache; + + if ( cache[ 0 ] === v ) return; + + gl.uniform1i( this.addr, v ); + + cache[ 0 ] = v; + + } + + // Single float vector (from flat array or THREE.VectorN) + + function setValue2fv( gl, v ) { + + var cache = this.cache; + + if ( v.x !== undefined ) { + + if ( cache[ 0 ] !== v.x || cache[ 1 ] !== v.y ) { + + gl.uniform2f( this.addr, v.x, v.y ); + + cache[ 0 ] = v.x; + cache[ 1 ] = v.y; + + } + + } else { + + if ( arraysEqual( cache, v ) ) return; + + gl.uniform2fv( this.addr, v ); + + copyArray( cache, v ); + + } + + } + + function setValue3fv( gl, v ) { + + var cache = this.cache; + + if ( v.x !== undefined ) { + + if ( cache[ 0 ] !== v.x || cache[ 1 ] !== v.y || cache[ 2 ] !== v.z ) { + + gl.uniform3f( this.addr, v.x, v.y, v.z ); + + cache[ 0 ] = v.x; + cache[ 1 ] = v.y; + cache[ 2 ] = v.z; + + } + + } else if ( v.r !== undefined ) { + + if ( cache[ 0 ] !== v.r || cache[ 1 ] !== v.g || cache[ 2 ] !== v.b ) { + + gl.uniform3f( this.addr, v.r, v.g, v.b ); + + cache[ 0 ] = v.r; + cache[ 1 ] = v.g; + cache[ 2 ] = v.b; + + } + + } else { + + if ( arraysEqual( cache, v ) ) return; + + gl.uniform3fv( this.addr, v ); + + copyArray( cache, v ); + + } + + } + + function setValue4fv( gl, v ) { + + var cache = this.cache; + + if ( v.x !== undefined ) { + + if ( cache[ 0 ] !== v.x || cache[ 1 ] !== v.y || cache[ 2 ] !== v.z || cache[ 3 ] !== v.w ) { + + gl.uniform4f( this.addr, v.x, v.y, v.z, v.w ); + + cache[ 0 ] = v.x; + cache[ 1 ] = v.y; + cache[ 2 ] = v.z; + cache[ 3 ] = v.w; + + } + + } else { + + if ( arraysEqual( cache, v ) ) return; + + gl.uniform4fv( this.addr, v ); + + copyArray( cache, v ); + + } + + } + + // Single matrix (from flat array or MatrixN) + + function setValue2fm( gl, v ) { + + var cache = this.cache; + var elements = v.elements; + + if ( elements === undefined ) { + + if ( arraysEqual( cache, v ) ) return; + + gl.uniformMatrix2fv( this.addr, false, v ); + + copyArray( cache, v ); + + } else { + + if ( arraysEqual( cache, elements ) ) return; + + mat2array.set( elements ); + + gl.uniformMatrix2fv( this.addr, false, mat2array ); + + copyArray( cache, elements ); + + } + + } + + function setValue3fm( gl, v ) { + + var cache = this.cache; + var elements = v.elements; + + if ( elements === undefined ) { + + if ( arraysEqual( cache, v ) ) return; + + gl.uniformMatrix3fv( this.addr, false, v ); + + copyArray( cache, v ); + + } else { + + if ( arraysEqual( cache, elements ) ) return; + + mat3array.set( elements ); + + gl.uniformMatrix3fv( this.addr, false, mat3array ); + + copyArray( cache, elements ); + + } + + } + + function setValue4fm( gl, v ) { + + var cache = this.cache; + var elements = v.elements; + + if ( elements === undefined ) { + + if ( arraysEqual( cache, v ) ) return; + + gl.uniformMatrix4fv( this.addr, false, v ); + + copyArray( cache, v ); + + } else { + + if ( arraysEqual( cache, elements ) ) return; + + mat4array.set( elements ); + + gl.uniformMatrix4fv( this.addr, false, mat4array ); + + copyArray( cache, elements ); + + } + + } + + // Single texture (2D / Cube) + + function setValueT1( gl, v, renderer ) { + + var cache = this.cache; + var unit = renderer.allocTextureUnit(); + + if ( cache[ 0 ] !== unit ) { + + gl.uniform1i( this.addr, unit ); + cache[ 0 ] = unit; + + } + + renderer.setTexture2D( v || emptyTexture, unit ); + + } + + function setValueT6( gl, v, renderer ) { + + var cache = this.cache; + var unit = renderer.allocTextureUnit(); + + if ( cache[ 0 ] !== unit ) { + + gl.uniform1i( this.addr, unit ); + cache[ 0 ] = unit; + + } + + renderer.setTextureCube( v || emptyCubeTexture, unit ); + + } + + // Integer / Boolean vectors or arrays thereof (always flat arrays) + + function setValue2iv( gl, v ) { + + var cache = this.cache; + + if ( arraysEqual( cache, v ) ) return; + + gl.uniform2iv( this.addr, v ); + + copyArray( cache, v ); + + } + + function setValue3iv( gl, v ) { + + var cache = this.cache; + + if ( arraysEqual( cache, v ) ) return; + + gl.uniform3iv( this.addr, v ); + + copyArray( cache, v ); + + } + + function setValue4iv( gl, v ) { + + var cache = this.cache; + + if ( arraysEqual( cache, v ) ) return; + + gl.uniform4iv( this.addr, v ); + + copyArray( cache, v ); + + } + + // Helper to pick the right setter for the singular case + + function getSingularSetter( type ) { + + switch ( type ) { + + case 0x1406: return setValue1f; // FLOAT + case 0x8b50: return setValue2fv; // _VEC2 + case 0x8b51: return setValue3fv; // _VEC3 + case 0x8b52: return setValue4fv; // _VEC4 + + case 0x8b5a: return setValue2fm; // _MAT2 + case 0x8b5b: return setValue3fm; // _MAT3 + case 0x8b5c: return setValue4fm; // _MAT4 + + case 0x8b5e: case 0x8d66: return setValueT1; // SAMPLER_2D, SAMPLER_EXTERNAL_OES + case 0x8b60: return setValueT6; // SAMPLER_CUBE + + case 0x1404: case 0x8b56: return setValue1i; // INT, BOOL + case 0x8b53: case 0x8b57: return setValue2iv; // _VEC2 + case 0x8b54: case 0x8b58: return setValue3iv; // _VEC3 + case 0x8b55: case 0x8b59: return setValue4iv; // _VEC4 + + } + + } + + // Array of scalars + + function setValue1fv( gl, v ) { + + var cache = this.cache; + + if ( arraysEqual( cache, v ) ) return; + + gl.uniform1fv( this.addr, v ); + + copyArray( cache, v ); + + } + function setValue1iv( gl, v ) { + + var cache = this.cache; + + if ( arraysEqual( cache, v ) ) return; + + gl.uniform1iv( this.addr, v ); + + copyArray( cache, v ); + + } + + // Array of vectors (flat or from THREE classes) + + function setValueV2a( gl, v ) { + + var cache = this.cache; + var data = flatten( v, this.size, 2 ); + + if ( arraysEqual( cache, data ) ) return; + + gl.uniform2fv( this.addr, data ); + + this.updateCache( data ); + + } + + function setValueV3a( gl, v ) { + + var cache = this.cache; + var data = flatten( v, this.size, 3 ); + + if ( arraysEqual( cache, data ) ) return; + + gl.uniform3fv( this.addr, data ); + + this.updateCache( data ); + + } + + function setValueV4a( gl, v ) { + + var cache = this.cache; + var data = flatten( v, this.size, 4 ); + + if ( arraysEqual( cache, data ) ) return; + + gl.uniform4fv( this.addr, data ); + + this.updateCache( data ); + + } + + // Array of matrices (flat or from THREE clases) + + function setValueM2a( gl, v ) { + + var cache = this.cache; + var data = flatten( v, this.size, 4 ); + + if ( arraysEqual( cache, data ) ) return; + + gl.uniformMatrix2fv( this.addr, false, data ); + + this.updateCache( data ); + + } + + function setValueM3a( gl, v ) { + + var cache = this.cache; + var data = flatten( v, this.size, 9 ); + + if ( arraysEqual( cache, data ) ) return; + + gl.uniformMatrix3fv( this.addr, false, data ); + + this.updateCache( data ); + + } + + function setValueM4a( gl, v ) { + + var cache = this.cache; + var data = flatten( v, this.size, 16 ); + + if ( arraysEqual( cache, data ) ) return; + + gl.uniformMatrix4fv( this.addr, false, data ); + + this.updateCache( data ); + + } + + // Array of textures (2D / Cube) + + function setValueT1a( gl, v, renderer ) { + + var cache = this.cache; + var n = v.length; + + var units = allocTexUnits( renderer, n ); + + if ( arraysEqual( cache, units ) === false ) { + + gl.uniform1iv( this.addr, units ); + copyArray( cache, units ); + + } + + for ( var i = 0; i !== n; ++ i ) { + + renderer.setTexture2D( v[ i ] || emptyTexture, units[ i ] ); + + } + + } + + function setValueT6a( gl, v, renderer ) { + + var cache = this.cache; + var n = v.length; + + var units = allocTexUnits( renderer, n ); + + if ( arraysEqual( cache, units ) === false ) { + + gl.uniform1iv( this.addr, units ); + copyArray( cache, units ); + + } + + for ( var i = 0; i !== n; ++ i ) { + + renderer.setTextureCube( v[ i ] || emptyCubeTexture, units[ i ] ); + + } + + } + + // Helper to pick the right setter for a pure (bottom-level) array + + function getPureArraySetter( type ) { + + switch ( type ) { + + case 0x1406: return setValue1fv; // FLOAT + case 0x8b50: return setValueV2a; // _VEC2 + case 0x8b51: return setValueV3a; // _VEC3 + case 0x8b52: return setValueV4a; // _VEC4 + + case 0x8b5a: return setValueM2a; // _MAT2 + case 0x8b5b: return setValueM3a; // _MAT3 + case 0x8b5c: return setValueM4a; // _MAT4 + + case 0x8b5e: return setValueT1a; // SAMPLER_2D + case 0x8b60: return setValueT6a; // SAMPLER_CUBE + + case 0x1404: case 0x8b56: return setValue1iv; // INT, BOOL + case 0x8b53: case 0x8b57: return setValue2iv; // _VEC2 + case 0x8b54: case 0x8b58: return setValue3iv; // _VEC3 + case 0x8b55: case 0x8b59: return setValue4iv; // _VEC4 + + } + + } + + // --- Uniform Classes --- + + function SingleUniform( id, activeInfo, addr ) { + + this.id = id; + this.addr = addr; + this.cache = []; + this.setValue = getSingularSetter( activeInfo.type ); + + // this.path = activeInfo.name; // DEBUG + + } + + function PureArrayUniform( id, activeInfo, addr ) { + + this.id = id; + this.addr = addr; + this.cache = []; + this.size = activeInfo.size; + this.setValue = getPureArraySetter( activeInfo.type ); + + // this.path = activeInfo.name; // DEBUG + + } + + PureArrayUniform.prototype.updateCache = function ( data ) { + + var cache = this.cache; + + if ( data instanceof Float32Array && cache.length !== data.length ) { + + this.cache = new Float32Array( data.length ); + + } + + copyArray( cache, data ); + + }; + + function StructuredUniform( id ) { + + this.id = id; + + UniformContainer.call( this ); // mix-in + + } + + StructuredUniform.prototype.setValue = function ( gl, value ) { + + // Note: Don't need an extra 'renderer' parameter, since samplers + // are not allowed in structured uniforms. + + var seq = this.seq; + + for ( var i = 0, n = seq.length; i !== n; ++ i ) { + + var u = seq[ i ]; + u.setValue( gl, value[ u.id ] ); + + } + + }; + + // --- Top-level --- + + // Parser - builds up the property tree from the path strings + + var RePathPart = /([\w\d_]+)(\])?(\[|\.)?/g; + + // extracts + // - the identifier (member name or array index) + // - followed by an optional right bracket (found when array index) + // - followed by an optional left bracket or dot (type of subscript) + // + // Note: These portions can be read in a non-overlapping fashion and + // allow straightforward parsing of the hierarchy that WebGL encodes + // in the uniform names. + + function addUniform( container, uniformObject ) { + + container.seq.push( uniformObject ); + container.map[ uniformObject.id ] = uniformObject; + + } + + function parseUniform( activeInfo, addr, container ) { + + var path = activeInfo.name, + pathLength = path.length; + + // reset RegExp object, because of the early exit of a previous run + RePathPart.lastIndex = 0; + + while ( true ) { + + var match = RePathPart.exec( path ), + matchEnd = RePathPart.lastIndex, + + id = match[ 1 ], + idIsIndex = match[ 2 ] === ']', + subscript = match[ 3 ]; + + if ( idIsIndex ) id = id | 0; // convert to integer + + if ( subscript === undefined || subscript === '[' && matchEnd + 2 === pathLength ) { + + // bare name or "pure" bottom-level array "[0]" suffix + + addUniform( container, subscript === undefined ? + new SingleUniform( id, activeInfo, addr ) : + new PureArrayUniform( id, activeInfo, addr ) ); + + break; + + } else { + + // step into inner node / create it in case it doesn't exist + + var map = container.map, next = map[ id ]; + + if ( next === undefined ) { + + next = new StructuredUniform( id ); + addUniform( container, next ); + + } + + container = next; + + } + + } + + } + + // Root Container + + function WebGLUniforms( gl, program, renderer ) { + + UniformContainer.call( this ); + + this.renderer = renderer; + + var n = gl.getProgramParameter( program, gl.ACTIVE_UNIFORMS ); + + for ( var i = 0; i < n; ++ i ) { + + var info = gl.getActiveUniform( program, i ), + addr = gl.getUniformLocation( program, info.name ); + + parseUniform( info, addr, this ); + + } + + } + + WebGLUniforms.prototype.setValue = function ( gl, name, value ) { + + var u = this.map[ name ]; + + if ( u !== undefined ) u.setValue( gl, value, this.renderer ); + + }; + + WebGLUniforms.prototype.setOptional = function ( gl, object, name ) { + + var v = object[ name ]; + + if ( v !== undefined ) this.setValue( gl, name, v ); + + }; + + + // Static interface + + WebGLUniforms.upload = function ( gl, seq, values, renderer ) { + + for ( var i = 0, n = seq.length; i !== n; ++ i ) { + + var u = seq[ i ], + v = values[ u.id ]; + + if ( v.needsUpdate !== false ) { + + // note: always updating when .needsUpdate is undefined + u.setValue( gl, v.value, renderer ); + + } + + } + + }; + + WebGLUniforms.seqWithValue = function ( seq, values ) { + + var r = []; + + for ( var i = 0, n = seq.length; i !== n; ++ i ) { + + var u = seq[ i ]; + if ( u.id in values ) r.push( u ); + + } + + return r; + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function addLineNumbers( string ) { + + var lines = string.split( '\n' ); + + for ( var i = 0; i < lines.length; i ++ ) { + + lines[ i ] = ( i + 1 ) + ': ' + lines[ i ]; + + } + + return lines.join( '\n' ); + + } + + function WebGLShader( gl, type, string ) { + + var shader = gl.createShader( type ); + + gl.shaderSource( shader, string ); + gl.compileShader( shader ); + + if ( gl.getShaderParameter( shader, gl.COMPILE_STATUS ) === false ) { + + console.error( 'THREE.WebGLShader: Shader couldn\'t compile.' ); + + } + + if ( gl.getShaderInfoLog( shader ) !== '' ) { + + console.warn( 'THREE.WebGLShader: gl.getShaderInfoLog()', type === gl.VERTEX_SHADER ? 'vertex' : 'fragment', gl.getShaderInfoLog( shader ), addLineNumbers( string ) ); + + } + + // --enable-privileged-webgl-extension + // console.log( type, gl.getExtension( 'WEBGL_debug_shaders' ).getTranslatedShaderSource( shader ) ); + + return shader; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + var programIdCount = 0; + + function getEncodingComponents( encoding ) { + + switch ( encoding ) { + + case LinearEncoding: + return [ 'Linear', '( value )' ]; + case sRGBEncoding: + return [ 'sRGB', '( value )' ]; + case RGBEEncoding: + return [ 'RGBE', '( value )' ]; + case RGBM7Encoding: + return [ 'RGBM', '( value, 7.0 )' ]; + case RGBM16Encoding: + return [ 'RGBM', '( value, 16.0 )' ]; + case RGBDEncoding: + return [ 'RGBD', '( value, 256.0 )' ]; + case GammaEncoding: + return [ 'Gamma', '( value, float( GAMMA_FACTOR ) )' ]; + default: + throw new Error( 'unsupported encoding: ' + encoding ); + + } + + } + + function getTexelDecodingFunction( functionName, encoding ) { + + var components = getEncodingComponents( encoding ); + return 'vec4 ' + functionName + '( vec4 value ) { return ' + components[ 0 ] + 'ToLinear' + components[ 1 ] + '; }'; + + } + + function getTexelEncodingFunction( functionName, encoding ) { + + var components = getEncodingComponents( encoding ); + return 'vec4 ' + functionName + '( vec4 value ) { return LinearTo' + components[ 0 ] + components[ 1 ] + '; }'; + + } + + function getToneMappingFunction( functionName, toneMapping ) { + + var toneMappingName; + + switch ( toneMapping ) { + + case LinearToneMapping: + toneMappingName = 'Linear'; + break; + + case ReinhardToneMapping: + toneMappingName = 'Reinhard'; + break; + + case Uncharted2ToneMapping: + toneMappingName = 'Uncharted2'; + break; + + case CineonToneMapping: + toneMappingName = 'OptimizedCineon'; + break; + + default: + throw new Error( 'unsupported toneMapping: ' + toneMapping ); + + } + + return 'vec3 ' + functionName + '( vec3 color ) { return ' + toneMappingName + 'ToneMapping( color ); }'; + + } + + function generateExtensions( extensions, parameters, rendererExtensions ) { + + extensions = extensions || {}; + + var chunks = [ + ( extensions.derivatives || parameters.envMapCubeUV || parameters.bumpMap || parameters.normalMap || parameters.flatShading ) ? '#extension GL_OES_standard_derivatives : enable' : '', + ( extensions.fragDepth || parameters.logarithmicDepthBuffer ) && rendererExtensions.get( 'EXT_frag_depth' ) ? '#extension GL_EXT_frag_depth : enable' : '', + ( extensions.drawBuffers ) && rendererExtensions.get( 'WEBGL_draw_buffers' ) ? '#extension GL_EXT_draw_buffers : require' : '', + ( extensions.shaderTextureLOD || parameters.envMap ) && rendererExtensions.get( 'EXT_shader_texture_lod' ) ? '#extension GL_EXT_shader_texture_lod : enable' : '' + ]; + + return chunks.filter( filterEmptyLine ).join( '\n' ); + + } + + function generateDefines( defines ) { + + var chunks = []; + + for ( var name in defines ) { + + var value = defines[ name ]; + + if ( value === false ) continue; + + chunks.push( '#define ' + name + ' ' + value ); + + } + + return chunks.join( '\n' ); + + } + + function fetchAttributeLocations( gl, program ) { + + var attributes = {}; + + var n = gl.getProgramParameter( program, gl.ACTIVE_ATTRIBUTES ); + + for ( var i = 0; i < n; i ++ ) { + + var info = gl.getActiveAttrib( program, i ); + var name = info.name; + + // console.log( 'THREE.WebGLProgram: ACTIVE VERTEX ATTRIBUTE:', name, i ); + + attributes[ name ] = gl.getAttribLocation( program, name ); + + } + + return attributes; + + } + + function filterEmptyLine( string ) { + + return string !== ''; + + } + + function replaceLightNums( string, parameters ) { + + return string + .replace( /NUM_DIR_LIGHTS/g, parameters.numDirLights ) + .replace( /NUM_SPOT_LIGHTS/g, parameters.numSpotLights ) + .replace( /NUM_RECT_AREA_LIGHTS/g, parameters.numRectAreaLights ) + .replace( /NUM_POINT_LIGHTS/g, parameters.numPointLights ) + .replace( /NUM_HEMI_LIGHTS/g, parameters.numHemiLights ); + + } + + function replaceClippingPlaneNums( string, parameters ) { + + return string + .replace( /NUM_CLIPPING_PLANES/g, parameters.numClippingPlanes ) + .replace( /UNION_CLIPPING_PLANES/g, ( parameters.numClippingPlanes - parameters.numClipIntersection ) ); + + } + + function parseIncludes( string ) { + + var pattern = /^[ \t]*#include +<([\w\d.]+)>/gm; + + function replace( match, include ) { + + var replace = ShaderChunk[ include ]; + + if ( replace === undefined ) { + + throw new Error( 'Can not resolve #include <' + include + '>' ); + + } + + return parseIncludes( replace ); + + } + + return string.replace( pattern, replace ); + + } + + function unrollLoops( string ) { + + var pattern = /#pragma unroll_loop[\s]+?for \( int i \= (\d+)\; i < (\d+)\; i \+\+ \) \{([\s\S]+?)(?=\})\}/g; + + function replace( match, start, end, snippet ) { + + var unroll = ''; + + for ( var i = parseInt( start ); i < parseInt( end ); i ++ ) { + + unroll += snippet.replace( /\[ i \]/g, '[ ' + i + ' ]' ); + + } + + return unroll; + + } + + return string.replace( pattern, replace ); + + } + + function WebGLProgram( renderer, extensions, code, material, shader, parameters ) { + + var gl = renderer.context; + + var defines = material.defines; + + var vertexShader = shader.vertexShader; + var fragmentShader = shader.fragmentShader; + + var shadowMapTypeDefine = 'SHADOWMAP_TYPE_BASIC'; + + if ( parameters.shadowMapType === PCFShadowMap ) { + + shadowMapTypeDefine = 'SHADOWMAP_TYPE_PCF'; + + } else if ( parameters.shadowMapType === PCFSoftShadowMap ) { + + shadowMapTypeDefine = 'SHADOWMAP_TYPE_PCF_SOFT'; + + } + + var envMapTypeDefine = 'ENVMAP_TYPE_CUBE'; + var envMapModeDefine = 'ENVMAP_MODE_REFLECTION'; + var envMapBlendingDefine = 'ENVMAP_BLENDING_MULTIPLY'; + + if ( parameters.envMap ) { + + switch ( material.envMap.mapping ) { + + case CubeReflectionMapping: + case CubeRefractionMapping: + envMapTypeDefine = 'ENVMAP_TYPE_CUBE'; + break; + + case CubeUVReflectionMapping: + case CubeUVRefractionMapping: + envMapTypeDefine = 'ENVMAP_TYPE_CUBE_UV'; + break; + + case EquirectangularReflectionMapping: + case EquirectangularRefractionMapping: + envMapTypeDefine = 'ENVMAP_TYPE_EQUIREC'; + break; + + case SphericalReflectionMapping: + envMapTypeDefine = 'ENVMAP_TYPE_SPHERE'; + break; + + } + + switch ( material.envMap.mapping ) { + + case CubeRefractionMapping: + case EquirectangularRefractionMapping: + envMapModeDefine = 'ENVMAP_MODE_REFRACTION'; + break; + + } + + switch ( material.combine ) { + + case MultiplyOperation: + envMapBlendingDefine = 'ENVMAP_BLENDING_MULTIPLY'; + break; + + case MixOperation: + envMapBlendingDefine = 'ENVMAP_BLENDING_MIX'; + break; + + case AddOperation: + envMapBlendingDefine = 'ENVMAP_BLENDING_ADD'; + break; + + } + + } + + var gammaFactorDefine = ( renderer.gammaFactor > 0 ) ? renderer.gammaFactor : 1.0; + + // console.log( 'building new program ' ); + + // + + var customExtensions = generateExtensions( material.extensions, parameters, extensions ); + + var customDefines = generateDefines( defines ); + + // + + var program = gl.createProgram(); + + var prefixVertex, prefixFragment; + + if ( material.isRawShaderMaterial ) { + + prefixVertex = [ + + customDefines + + ].filter( filterEmptyLine ).join( '\n' ); + + if ( prefixVertex.length > 0 ) { + + prefixVertex += '\n'; + + } + + prefixFragment = [ + + customExtensions, + customDefines + + ].filter( filterEmptyLine ).join( '\n' ); + + if ( prefixFragment.length > 0 ) { + + prefixFragment += '\n'; + + } + + } else { + + prefixVertex = [ + + 'precision ' + parameters.precision + ' float;', + 'precision ' + parameters.precision + ' int;', + + '#define SHADER_NAME ' + shader.name, + + customDefines, + + parameters.supportsVertexTextures ? '#define VERTEX_TEXTURES' : '', + + '#define GAMMA_FACTOR ' + gammaFactorDefine, + + '#define MAX_BONES ' + parameters.maxBones, + ( parameters.useFog && parameters.fog ) ? '#define USE_FOG' : '', + ( parameters.useFog && parameters.fogExp ) ? '#define FOG_EXP2' : '', + + parameters.map ? '#define USE_MAP' : '', + parameters.envMap ? '#define USE_ENVMAP' : '', + parameters.envMap ? '#define ' + envMapModeDefine : '', + parameters.lightMap ? '#define USE_LIGHTMAP' : '', + parameters.aoMap ? '#define USE_AOMAP' : '', + parameters.emissiveMap ? '#define USE_EMISSIVEMAP' : '', + parameters.bumpMap ? '#define USE_BUMPMAP' : '', + parameters.normalMap ? '#define USE_NORMALMAP' : '', + parameters.displacementMap && parameters.supportsVertexTextures ? '#define USE_DISPLACEMENTMAP' : '', + parameters.specularMap ? '#define USE_SPECULARMAP' : '', + parameters.roughnessMap ? '#define USE_ROUGHNESSMAP' : '', + parameters.metalnessMap ? '#define USE_METALNESSMAP' : '', + parameters.alphaMap ? '#define USE_ALPHAMAP' : '', + parameters.vertexColors ? '#define USE_COLOR' : '', + + parameters.flatShading ? '#define FLAT_SHADED' : '', + + parameters.skinning ? '#define USE_SKINNING' : '', + parameters.useVertexTexture ? '#define BONE_TEXTURE' : '', + + parameters.morphTargets ? '#define USE_MORPHTARGETS' : '', + parameters.morphNormals && parameters.flatShading === false ? '#define USE_MORPHNORMALS' : '', + parameters.doubleSided ? '#define DOUBLE_SIDED' : '', + parameters.flipSided ? '#define FLIP_SIDED' : '', + + parameters.shadowMapEnabled ? '#define USE_SHADOWMAP' : '', + parameters.shadowMapEnabled ? '#define ' + shadowMapTypeDefine : '', + + parameters.sizeAttenuation ? '#define USE_SIZEATTENUATION' : '', + + parameters.logarithmicDepthBuffer ? '#define USE_LOGDEPTHBUF' : '', + parameters.logarithmicDepthBuffer && extensions.get( 'EXT_frag_depth' ) ? '#define USE_LOGDEPTHBUF_EXT' : '', + + 'uniform mat4 modelMatrix;', + 'uniform mat4 modelViewMatrix;', + 'uniform mat4 projectionMatrix;', + 'uniform mat4 viewMatrix;', + 'uniform mat3 normalMatrix;', + 'uniform vec3 cameraPosition;', + + 'attribute vec3 position;', + 'attribute vec3 normal;', + 'attribute vec2 uv;', + + '#ifdef USE_COLOR', + + ' attribute vec3 color;', + + '#endif', + + '#ifdef USE_MORPHTARGETS', + + ' attribute vec3 morphTarget0;', + ' attribute vec3 morphTarget1;', + ' attribute vec3 morphTarget2;', + ' attribute vec3 morphTarget3;', + + ' #ifdef USE_MORPHNORMALS', + + ' attribute vec3 morphNormal0;', + ' attribute vec3 morphNormal1;', + ' attribute vec3 morphNormal2;', + ' attribute vec3 morphNormal3;', + + ' #else', + + ' attribute vec3 morphTarget4;', + ' attribute vec3 morphTarget5;', + ' attribute vec3 morphTarget6;', + ' attribute vec3 morphTarget7;', + + ' #endif', + + '#endif', + + '#ifdef USE_SKINNING', + + ' attribute vec4 skinIndex;', + ' attribute vec4 skinWeight;', + + '#endif', + + '\n' + + ].filter( filterEmptyLine ).join( '\n' ); + + prefixFragment = [ + + customExtensions, + + 'precision ' + parameters.precision + ' float;', + 'precision ' + parameters.precision + ' int;', + + '#define SHADER_NAME ' + shader.name, + + customDefines, + + parameters.alphaTest ? '#define ALPHATEST ' + parameters.alphaTest + ( parameters.alphaTest % 1 ? '' : '.0' ) : '', // add '.0' if integer + + '#define GAMMA_FACTOR ' + gammaFactorDefine, + + ( parameters.useFog && parameters.fog ) ? '#define USE_FOG' : '', + ( parameters.useFog && parameters.fogExp ) ? '#define FOG_EXP2' : '', + + parameters.map ? '#define USE_MAP' : '', + parameters.envMap ? '#define USE_ENVMAP' : '', + parameters.envMap ? '#define ' + envMapTypeDefine : '', + parameters.envMap ? '#define ' + envMapModeDefine : '', + parameters.envMap ? '#define ' + envMapBlendingDefine : '', + parameters.lightMap ? '#define USE_LIGHTMAP' : '', + parameters.aoMap ? '#define USE_AOMAP' : '', + parameters.emissiveMap ? '#define USE_EMISSIVEMAP' : '', + parameters.bumpMap ? '#define USE_BUMPMAP' : '', + parameters.normalMap ? '#define USE_NORMALMAP' : '', + parameters.specularMap ? '#define USE_SPECULARMAP' : '', + parameters.roughnessMap ? '#define USE_ROUGHNESSMAP' : '', + parameters.metalnessMap ? '#define USE_METALNESSMAP' : '', + parameters.alphaMap ? '#define USE_ALPHAMAP' : '', + parameters.vertexColors ? '#define USE_COLOR' : '', + + parameters.gradientMap ? '#define USE_GRADIENTMAP' : '', + + parameters.flatShading ? '#define FLAT_SHADED' : '', + + parameters.doubleSided ? '#define DOUBLE_SIDED' : '', + parameters.flipSided ? '#define FLIP_SIDED' : '', + + parameters.shadowMapEnabled ? '#define USE_SHADOWMAP' : '', + parameters.shadowMapEnabled ? '#define ' + shadowMapTypeDefine : '', + + parameters.premultipliedAlpha ? '#define PREMULTIPLIED_ALPHA' : '', + + parameters.physicallyCorrectLights ? '#define PHYSICALLY_CORRECT_LIGHTS' : '', + + parameters.logarithmicDepthBuffer ? '#define USE_LOGDEPTHBUF' : '', + parameters.logarithmicDepthBuffer && extensions.get( 'EXT_frag_depth' ) ? '#define USE_LOGDEPTHBUF_EXT' : '', + + parameters.envMap && extensions.get( 'EXT_shader_texture_lod' ) ? '#define TEXTURE_LOD_EXT' : '', + + 'uniform mat4 viewMatrix;', + 'uniform vec3 cameraPosition;', + + ( parameters.toneMapping !== NoToneMapping ) ? '#define TONE_MAPPING' : '', + ( parameters.toneMapping !== NoToneMapping ) ? ShaderChunk[ 'tonemapping_pars_fragment' ] : '', // this code is required here because it is used by the toneMapping() function defined below + ( parameters.toneMapping !== NoToneMapping ) ? getToneMappingFunction( 'toneMapping', parameters.toneMapping ) : '', + + parameters.dithering ? '#define DITHERING' : '', + + ( parameters.outputEncoding || parameters.mapEncoding || parameters.envMapEncoding || parameters.emissiveMapEncoding ) ? ShaderChunk[ 'encodings_pars_fragment' ] : '', // this code is required here because it is used by the various encoding/decoding function defined below + parameters.mapEncoding ? getTexelDecodingFunction( 'mapTexelToLinear', parameters.mapEncoding ) : '', + parameters.envMapEncoding ? getTexelDecodingFunction( 'envMapTexelToLinear', parameters.envMapEncoding ) : '', + parameters.emissiveMapEncoding ? getTexelDecodingFunction( 'emissiveMapTexelToLinear', parameters.emissiveMapEncoding ) : '', + parameters.outputEncoding ? getTexelEncodingFunction( 'linearToOutputTexel', parameters.outputEncoding ) : '', + + parameters.depthPacking ? '#define DEPTH_PACKING ' + material.depthPacking : '', + + '\n' + + ].filter( filterEmptyLine ).join( '\n' ); + + } + + vertexShader = parseIncludes( vertexShader ); + vertexShader = replaceLightNums( vertexShader, parameters ); + vertexShader = replaceClippingPlaneNums( vertexShader, parameters ); + + fragmentShader = parseIncludes( fragmentShader ); + fragmentShader = replaceLightNums( fragmentShader, parameters ); + fragmentShader = replaceClippingPlaneNums( fragmentShader, parameters ); + + vertexShader = unrollLoops( vertexShader ); + fragmentShader = unrollLoops( fragmentShader ); + + var vertexGlsl = prefixVertex + vertexShader; + var fragmentGlsl = prefixFragment + fragmentShader; + + // console.log( '*VERTEX*', vertexGlsl ); + // console.log( '*FRAGMENT*', fragmentGlsl ); + + var glVertexShader = WebGLShader( gl, gl.VERTEX_SHADER, vertexGlsl ); + var glFragmentShader = WebGLShader( gl, gl.FRAGMENT_SHADER, fragmentGlsl ); + + gl.attachShader( program, glVertexShader ); + gl.attachShader( program, glFragmentShader ); + + // Force a particular attribute to index 0. + + if ( material.index0AttributeName !== undefined ) { + + gl.bindAttribLocation( program, 0, material.index0AttributeName ); + + } else if ( parameters.morphTargets === true ) { + + // programs with morphTargets displace position out of attribute 0 + gl.bindAttribLocation( program, 0, 'position' ); + + } + + gl.linkProgram( program ); + + var programLog = gl.getProgramInfoLog( program ).trim(); + var vertexLog = gl.getShaderInfoLog( glVertexShader ).trim(); + var fragmentLog = gl.getShaderInfoLog( glFragmentShader ).trim(); + + var runnable = true; + var haveDiagnostics = true; + + // console.log( '**VERTEX**', gl.getExtension( 'WEBGL_debug_shaders' ).getTranslatedShaderSource( glVertexShader ) ); + // console.log( '**FRAGMENT**', gl.getExtension( 'WEBGL_debug_shaders' ).getTranslatedShaderSource( glFragmentShader ) ); + + if ( gl.getProgramParameter( program, gl.LINK_STATUS ) === false ) { + + runnable = false; + + console.error( 'THREE.WebGLProgram: shader error: ', gl.getError(), 'gl.VALIDATE_STATUS', gl.getProgramParameter( program, gl.VALIDATE_STATUS ), 'gl.getProgramInfoLog', programLog, vertexLog, fragmentLog ); + + } else if ( programLog !== '' ) { + + console.warn( 'THREE.WebGLProgram: gl.getProgramInfoLog()', programLog ); + + } else if ( vertexLog === '' || fragmentLog === '' ) { + + haveDiagnostics = false; + + } + + if ( haveDiagnostics ) { + + this.diagnostics = { + + runnable: runnable, + material: material, + + programLog: programLog, + + vertexShader: { + + log: vertexLog, + prefix: prefixVertex + + }, + + fragmentShader: { + + log: fragmentLog, + prefix: prefixFragment + + } + + }; + + } + + // clean up + + gl.deleteShader( glVertexShader ); + gl.deleteShader( glFragmentShader ); + + // set up caching for uniform locations + + var cachedUniforms; + + this.getUniforms = function () { + + if ( cachedUniforms === undefined ) { + + cachedUniforms = new WebGLUniforms( gl, program, renderer ); + + } + + return cachedUniforms; + + }; + + // set up caching for attribute locations + + var cachedAttributes; + + this.getAttributes = function () { + + if ( cachedAttributes === undefined ) { + + cachedAttributes = fetchAttributeLocations( gl, program ); + + } + + return cachedAttributes; + + }; + + // free resource + + this.destroy = function () { + + gl.deleteProgram( program ); + this.program = undefined; + + }; + + // DEPRECATED + + Object.defineProperties( this, { + + uniforms: { + get: function () { + + console.warn( 'THREE.WebGLProgram: .uniforms is now .getUniforms().' ); + return this.getUniforms(); + + } + }, + + attributes: { + get: function () { + + console.warn( 'THREE.WebGLProgram: .attributes is now .getAttributes().' ); + return this.getAttributes(); + + } + } + + } ); + + + // + + this.name = shader.name; + this.id = programIdCount ++; + this.code = code; + this.usedTimes = 1; + this.program = program; + this.vertexShader = glVertexShader; + this.fragmentShader = glFragmentShader; + + return this; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLPrograms( renderer, extensions, capabilities ) { + + var programs = []; + + var shaderIDs = { + MeshDepthMaterial: 'depth', + MeshDistanceMaterial: 'distanceRGBA', + MeshNormalMaterial: 'normal', + MeshBasicMaterial: 'basic', + MeshLambertMaterial: 'lambert', + MeshPhongMaterial: 'phong', + MeshToonMaterial: 'phong', + MeshStandardMaterial: 'physical', + MeshPhysicalMaterial: 'physical', + LineBasicMaterial: 'basic', + LineDashedMaterial: 'dashed', + PointsMaterial: 'points', + ShadowMaterial: 'shadow' + }; + + var parameterNames = [ + "precision", "supportsVertexTextures", "map", "mapEncoding", "envMap", "envMapMode", "envMapEncoding", + "lightMap", "aoMap", "emissiveMap", "emissiveMapEncoding", "bumpMap", "normalMap", "displacementMap", "specularMap", + "roughnessMap", "metalnessMap", "gradientMap", + "alphaMap", "combine", "vertexColors", "fog", "useFog", "fogExp", + "flatShading", "sizeAttenuation", "logarithmicDepthBuffer", "skinning", + "maxBones", "useVertexTexture", "morphTargets", "morphNormals", + "maxMorphTargets", "maxMorphNormals", "premultipliedAlpha", + "numDirLights", "numPointLights", "numSpotLights", "numHemiLights", "numRectAreaLights", + "shadowMapEnabled", "shadowMapType", "toneMapping", 'physicallyCorrectLights', + "alphaTest", "doubleSided", "flipSided", "numClippingPlanes", "numClipIntersection", "depthPacking", "dithering" + ]; + + + function allocateBones( object ) { + + var skeleton = object.skeleton; + var bones = skeleton.bones; + + if ( capabilities.floatVertexTextures ) { + + return 1024; + + } else { + + // default for when object is not specified + // ( for example when prebuilding shader to be used with multiple objects ) + // + // - leave some extra space for other uniforms + // - limit here is ANGLE's 254 max uniform vectors + // (up to 54 should be safe) + + var nVertexUniforms = capabilities.maxVertexUniforms; + var nVertexMatrices = Math.floor( ( nVertexUniforms - 20 ) / 4 ); + + var maxBones = Math.min( nVertexMatrices, bones.length ); + + if ( maxBones < bones.length ) { + + console.warn( 'THREE.WebGLRenderer: Skeleton has ' + bones.length + ' bones. This GPU supports ' + maxBones + '.' ); + return 0; + + } + + return maxBones; + + } + + } + + function getTextureEncodingFromMap( map, gammaOverrideLinear ) { + + var encoding; + + if ( ! map ) { + + encoding = LinearEncoding; + + } else if ( map.isTexture ) { + + encoding = map.encoding; + + } else if ( map.isWebGLRenderTarget ) { + + console.warn( "THREE.WebGLPrograms.getTextureEncodingFromMap: don't use render targets as textures. Use their .texture property instead." ); + encoding = map.texture.encoding; + + } + + // add backwards compatibility for WebGLRenderer.gammaInput/gammaOutput parameter, should probably be removed at some point. + if ( encoding === LinearEncoding && gammaOverrideLinear ) { + + encoding = GammaEncoding; + + } + + return encoding; + + } + + this.getParameters = function ( material, lights, shadows, fog, nClipPlanes, nClipIntersection, object ) { + + var shaderID = shaderIDs[ material.type ]; + + // heuristics to create shader parameters according to lights in the scene + // (not to blow over maxLights budget) + + var maxBones = object.isSkinnedMesh ? allocateBones( object ) : 0; + var precision = capabilities.precision; + + if ( material.precision !== null ) { + + precision = capabilities.getMaxPrecision( material.precision ); + + if ( precision !== material.precision ) { + + console.warn( 'THREE.WebGLProgram.getParameters:', material.precision, 'not supported, using', precision, 'instead.' ); + + } + + } + + var currentRenderTarget = renderer.getRenderTarget(); + + var parameters = { + + shaderID: shaderID, + + precision: precision, + supportsVertexTextures: capabilities.vertexTextures, + outputEncoding: getTextureEncodingFromMap( ( ! currentRenderTarget ) ? null : currentRenderTarget.texture, renderer.gammaOutput ), + map: !! material.map, + mapEncoding: getTextureEncodingFromMap( material.map, renderer.gammaInput ), + envMap: !! material.envMap, + envMapMode: material.envMap && material.envMap.mapping, + envMapEncoding: getTextureEncodingFromMap( material.envMap, renderer.gammaInput ), + envMapCubeUV: ( !! material.envMap ) && ( ( material.envMap.mapping === CubeUVReflectionMapping ) || ( material.envMap.mapping === CubeUVRefractionMapping ) ), + lightMap: !! material.lightMap, + aoMap: !! material.aoMap, + emissiveMap: !! material.emissiveMap, + emissiveMapEncoding: getTextureEncodingFromMap( material.emissiveMap, renderer.gammaInput ), + bumpMap: !! material.bumpMap, + normalMap: !! material.normalMap, + displacementMap: !! material.displacementMap, + roughnessMap: !! material.roughnessMap, + metalnessMap: !! material.metalnessMap, + specularMap: !! material.specularMap, + alphaMap: !! material.alphaMap, + + gradientMap: !! material.gradientMap, + + combine: material.combine, + + vertexColors: material.vertexColors, + + fog: !! fog, + useFog: material.fog, + fogExp: ( fog && fog.isFogExp2 ), + + flatShading: material.flatShading, + + sizeAttenuation: material.sizeAttenuation, + logarithmicDepthBuffer: capabilities.logarithmicDepthBuffer, + + skinning: material.skinning && maxBones > 0, + maxBones: maxBones, + useVertexTexture: capabilities.floatVertexTextures, + + morphTargets: material.morphTargets, + morphNormals: material.morphNormals, + maxMorphTargets: renderer.maxMorphTargets, + maxMorphNormals: renderer.maxMorphNormals, + + numDirLights: lights.directional.length, + numPointLights: lights.point.length, + numSpotLights: lights.spot.length, + numRectAreaLights: lights.rectArea.length, + numHemiLights: lights.hemi.length, + + numClippingPlanes: nClipPlanes, + numClipIntersection: nClipIntersection, + + dithering: material.dithering, + + shadowMapEnabled: renderer.shadowMap.enabled && object.receiveShadow && shadows.length > 0, + shadowMapType: renderer.shadowMap.type, + + toneMapping: renderer.toneMapping, + physicallyCorrectLights: renderer.physicallyCorrectLights, + + premultipliedAlpha: material.premultipliedAlpha, + + alphaTest: material.alphaTest, + doubleSided: material.side === DoubleSide, + flipSided: material.side === BackSide, + + depthPacking: ( material.depthPacking !== undefined ) ? material.depthPacking : false + + }; + + return parameters; + + }; + + this.getProgramCode = function ( material, parameters ) { + + var array = []; + + if ( parameters.shaderID ) { + + array.push( parameters.shaderID ); + + } else { + + array.push( material.fragmentShader ); + array.push( material.vertexShader ); + + } + + if ( material.defines !== undefined ) { + + for ( var name in material.defines ) { + + array.push( name ); + array.push( material.defines[ name ] ); + + } + + } + + for ( var i = 0; i < parameterNames.length; i ++ ) { + + array.push( parameters[ parameterNames[ i ] ] ); + + } + + array.push( material.onBeforeCompile.toString() ); + + array.push( renderer.gammaOutput ); + + return array.join(); + + }; + + this.acquireProgram = function ( material, shader, parameters, code ) { + + var program; + + // Check if code has been already compiled + for ( var p = 0, pl = programs.length; p < pl; p ++ ) { + + var programInfo = programs[ p ]; + + if ( programInfo.code === code ) { + + program = programInfo; + ++ program.usedTimes; + + break; + + } + + } + + if ( program === undefined ) { + + program = new WebGLProgram( renderer, extensions, code, material, shader, parameters ); + programs.push( program ); + + } + + return program; + + }; + + this.releaseProgram = function ( program ) { + + if ( -- program.usedTimes === 0 ) { + + // Remove from unordered set + var i = programs.indexOf( program ); + programs[ i ] = programs[ programs.length - 1 ]; + programs.pop(); + + // Free WebGL resources + program.destroy(); + + } + + }; + + // Exposed for resource monitoring & error feedback via renderer.info: + this.programs = programs; + + } + + /** + * @author fordacious / fordacious.github.io + */ + + function WebGLProperties() { + + var properties = new WeakMap(); + + function get( object ) { + + var map = properties.get( object ); + + if ( map === undefined ) { + + map = {}; + properties.set( object, map ); + + } + + return map; + + } + + function remove( object ) { + + properties.delete( object ); + + } + + function update( object, key, value ) { + + properties.get( object )[ key ] = value; + + } + + function dispose() { + + properties = new WeakMap(); + + } + + return { + get: get, + remove: remove, + update: update, + dispose: dispose + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function painterSortStable( a, b ) { + + if ( a.renderOrder !== b.renderOrder ) { + + return a.renderOrder - b.renderOrder; + + } else if ( a.program && b.program && a.program !== b.program ) { + + return a.program.id - b.program.id; + + } else if ( a.material.id !== b.material.id ) { + + return a.material.id - b.material.id; + + } else if ( a.z !== b.z ) { + + return a.z - b.z; + + } else { + + return a.id - b.id; + + } + + } + + function reversePainterSortStable( a, b ) { + + if ( a.renderOrder !== b.renderOrder ) { + + return a.renderOrder - b.renderOrder; + + } if ( a.z !== b.z ) { + + return b.z - a.z; + + } else { + + return a.id - b.id; + + } + + } + + function WebGLRenderList() { + + var renderItems = []; + var renderItemsIndex = 0; + + var opaque = []; + var transparent = []; + + function init() { + + renderItemsIndex = 0; + + opaque.length = 0; + transparent.length = 0; + + } + + function push( object, geometry, material, z, group ) { + + var renderItem = renderItems[ renderItemsIndex ]; + + if ( renderItem === undefined ) { + + renderItem = { + id: object.id, + object: object, + geometry: geometry, + material: material, + program: material.program, + renderOrder: object.renderOrder, + z: z, + group: group + }; + + renderItems[ renderItemsIndex ] = renderItem; + + } else { + + renderItem.id = object.id; + renderItem.object = object; + renderItem.geometry = geometry; + renderItem.material = material; + renderItem.program = material.program; + renderItem.renderOrder = object.renderOrder; + renderItem.z = z; + renderItem.group = group; + + } + + ( material.transparent === true ? transparent : opaque ).push( renderItem ); + + renderItemsIndex ++; + + } + + function sort() { + + if ( opaque.length > 1 ) opaque.sort( painterSortStable ); + if ( transparent.length > 1 ) transparent.sort( reversePainterSortStable ); + + } + + return { + opaque: opaque, + transparent: transparent, + + init: init, + push: push, + + sort: sort + }; + + } + + function WebGLRenderLists() { + + var lists = {}; + + function get( scene, camera ) { + + var hash = scene.id + ',' + camera.id; + var list = lists[ hash ]; + + if ( list === undefined ) { + + // console.log( 'THREE.WebGLRenderLists:', hash ); + + list = new WebGLRenderList(); + lists[ hash ] = list; + + } + + return list; + + } + + function dispose() { + + lists = {}; + + } + + return { + get: get, + dispose: dispose + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function UniformsCache() { + + var lights = {}; + + return { + + get: function ( light ) { + + if ( lights[ light.id ] !== undefined ) { + + return lights[ light.id ]; + + } + + var uniforms; + + switch ( light.type ) { + + case 'DirectionalLight': + uniforms = { + direction: new Vector3(), + color: new Color(), + + shadow: false, + shadowBias: 0, + shadowRadius: 1, + shadowMapSize: new Vector2() + }; + break; + + case 'SpotLight': + uniforms = { + position: new Vector3(), + direction: new Vector3(), + color: new Color(), + distance: 0, + coneCos: 0, + penumbraCos: 0, + decay: 0, + + shadow: false, + shadowBias: 0, + shadowRadius: 1, + shadowMapSize: new Vector2() + }; + break; + + case 'PointLight': + uniforms = { + position: new Vector3(), + color: new Color(), + distance: 0, + decay: 0, + + shadow: false, + shadowBias: 0, + shadowRadius: 1, + shadowMapSize: new Vector2(), + shadowCameraNear: 1, + shadowCameraFar: 1000 + }; + break; + + case 'HemisphereLight': + uniforms = { + direction: new Vector3(), + skyColor: new Color(), + groundColor: new Color() + }; + break; + + case 'RectAreaLight': + uniforms = { + color: new Color(), + position: new Vector3(), + halfWidth: new Vector3(), + halfHeight: new Vector3() + // TODO (abelnation): set RectAreaLight shadow uniforms + }; + break; + + } + + lights[ light.id ] = uniforms; + + return uniforms; + + } + + }; + + } + + var count = 0; + + function WebGLLights() { + + var cache = new UniformsCache(); + + var state = { + + id: count ++, + + hash: '', + + ambient: [ 0, 0, 0 ], + directional: [], + directionalShadowMap: [], + directionalShadowMatrix: [], + spot: [], + spotShadowMap: [], + spotShadowMatrix: [], + rectArea: [], + point: [], + pointShadowMap: [], + pointShadowMatrix: [], + hemi: [] + + }; + + var vector3 = new Vector3(); + var matrix4 = new Matrix4(); + var matrix42 = new Matrix4(); + + function setup( lights, shadows, camera ) { + + var r = 0, g = 0, b = 0; + + var directionalLength = 0; + var pointLength = 0; + var spotLength = 0; + var rectAreaLength = 0; + var hemiLength = 0; + + var viewMatrix = camera.matrixWorldInverse; + + for ( var i = 0, l = lights.length; i < l; i ++ ) { + + var light = lights[ i ]; + + var color = light.color; + var intensity = light.intensity; + var distance = light.distance; + + var shadowMap = ( light.shadow && light.shadow.map ) ? light.shadow.map.texture : null; + + if ( light.isAmbientLight ) { + + r += color.r * intensity; + g += color.g * intensity; + b += color.b * intensity; + + } else if ( light.isDirectionalLight ) { + + var uniforms = cache.get( light ); + + uniforms.color.copy( light.color ).multiplyScalar( light.intensity ); + uniforms.direction.setFromMatrixPosition( light.matrixWorld ); + vector3.setFromMatrixPosition( light.target.matrixWorld ); + uniforms.direction.sub( vector3 ); + uniforms.direction.transformDirection( viewMatrix ); + + uniforms.shadow = light.castShadow; + + if ( light.castShadow ) { + + var shadow = light.shadow; + + uniforms.shadowBias = shadow.bias; + uniforms.shadowRadius = shadow.radius; + uniforms.shadowMapSize = shadow.mapSize; + + } + + state.directionalShadowMap[ directionalLength ] = shadowMap; + state.directionalShadowMatrix[ directionalLength ] = light.shadow.matrix; + state.directional[ directionalLength ] = uniforms; + + directionalLength ++; + + } else if ( light.isSpotLight ) { + + var uniforms = cache.get( light ); + + uniforms.position.setFromMatrixPosition( light.matrixWorld ); + uniforms.position.applyMatrix4( viewMatrix ); + + uniforms.color.copy( color ).multiplyScalar( intensity ); + uniforms.distance = distance; + + uniforms.direction.setFromMatrixPosition( light.matrixWorld ); + vector3.setFromMatrixPosition( light.target.matrixWorld ); + uniforms.direction.sub( vector3 ); + uniforms.direction.transformDirection( viewMatrix ); + + uniforms.coneCos = Math.cos( light.angle ); + uniforms.penumbraCos = Math.cos( light.angle * ( 1 - light.penumbra ) ); + uniforms.decay = ( light.distance === 0 ) ? 0.0 : light.decay; + + uniforms.shadow = light.castShadow; + + if ( light.castShadow ) { + + var shadow = light.shadow; + + uniforms.shadowBias = shadow.bias; + uniforms.shadowRadius = shadow.radius; + uniforms.shadowMapSize = shadow.mapSize; + + } + + state.spotShadowMap[ spotLength ] = shadowMap; + state.spotShadowMatrix[ spotLength ] = light.shadow.matrix; + state.spot[ spotLength ] = uniforms; + + spotLength ++; + + } else if ( light.isRectAreaLight ) { + + var uniforms = cache.get( light ); + + // (a) intensity is the total visible light emitted + //uniforms.color.copy( color ).multiplyScalar( intensity / ( light.width * light.height * Math.PI ) ); + + // (b) intensity is the brightness of the light + uniforms.color.copy( color ).multiplyScalar( intensity ); + + uniforms.position.setFromMatrixPosition( light.matrixWorld ); + uniforms.position.applyMatrix4( viewMatrix ); + + // extract local rotation of light to derive width/height half vectors + matrix42.identity(); + matrix4.copy( light.matrixWorld ); + matrix4.premultiply( viewMatrix ); + matrix42.extractRotation( matrix4 ); + + uniforms.halfWidth.set( light.width * 0.5, 0.0, 0.0 ); + uniforms.halfHeight.set( 0.0, light.height * 0.5, 0.0 ); + + uniforms.halfWidth.applyMatrix4( matrix42 ); + uniforms.halfHeight.applyMatrix4( matrix42 ); + + // TODO (abelnation): RectAreaLight distance? + // uniforms.distance = distance; + + state.rectArea[ rectAreaLength ] = uniforms; + + rectAreaLength ++; + + } else if ( light.isPointLight ) { + + var uniforms = cache.get( light ); + + uniforms.position.setFromMatrixPosition( light.matrixWorld ); + uniforms.position.applyMatrix4( viewMatrix ); + + uniforms.color.copy( light.color ).multiplyScalar( light.intensity ); + uniforms.distance = light.distance; + uniforms.decay = ( light.distance === 0 ) ? 0.0 : light.decay; + + uniforms.shadow = light.castShadow; + + if ( light.castShadow ) { + + var shadow = light.shadow; + + uniforms.shadowBias = shadow.bias; + uniforms.shadowRadius = shadow.radius; + uniforms.shadowMapSize = shadow.mapSize; + uniforms.shadowCameraNear = shadow.camera.near; + uniforms.shadowCameraFar = shadow.camera.far; + + } + + state.pointShadowMap[ pointLength ] = shadowMap; + state.pointShadowMatrix[ pointLength ] = light.shadow.matrix; + state.point[ pointLength ] = uniforms; + + pointLength ++; + + } else if ( light.isHemisphereLight ) { + + var uniforms = cache.get( light ); + + uniforms.direction.setFromMatrixPosition( light.matrixWorld ); + uniforms.direction.transformDirection( viewMatrix ); + uniforms.direction.normalize(); + + uniforms.skyColor.copy( light.color ).multiplyScalar( intensity ); + uniforms.groundColor.copy( light.groundColor ).multiplyScalar( intensity ); + + state.hemi[ hemiLength ] = uniforms; + + hemiLength ++; + + } + + } + + state.ambient[ 0 ] = r; + state.ambient[ 1 ] = g; + state.ambient[ 2 ] = b; + + state.directional.length = directionalLength; + state.spot.length = spotLength; + state.rectArea.length = rectAreaLength; + state.point.length = pointLength; + state.hemi.length = hemiLength; + + state.hash = state.id + ',' + directionalLength + ',' + pointLength + ',' + spotLength + ',' + rectAreaLength + ',' + hemiLength + ',' + shadows.length; + + } + + return { + setup: setup, + state: state + }; + + } + + /** + * @author Mugen87 / https://github.com/Mugen87 + */ + + function WebGLRenderState() { + + var lights = new WebGLLights(); + + var lightsArray = []; + var shadowsArray = []; + var spritesArray = []; + + function init() { + + lightsArray.length = 0; + shadowsArray.length = 0; + spritesArray.length = 0; + + } + + function pushLight( light ) { + + lightsArray.push( light ); + + } + + function pushShadow( shadowLight ) { + + shadowsArray.push( shadowLight ); + + } + + function pushSprite( shadowLight ) { + + spritesArray.push( shadowLight ); + + } + + function setupLights( camera ) { + + lights.setup( lightsArray, shadowsArray, camera ); + + } + + var state = { + lightsArray: lightsArray, + shadowsArray: shadowsArray, + spritesArray: spritesArray, + + lights: lights + }; + + return { + init: init, + state: state, + setupLights: setupLights, + + pushLight: pushLight, + pushShadow: pushShadow, + pushSprite: pushSprite + }; + + } + + function WebGLRenderStates() { + + var renderStates = {}; + + function get( scene, camera ) { + + var hash = scene.id + ',' + camera.id; + + var renderState = renderStates[ hash ]; + + if ( renderState === undefined ) { + + renderState = new WebGLRenderState(); + renderStates[ hash ] = renderState; + + } + + return renderState; + + } + + function dispose() { + + renderStates = {}; + + } + + return { + get: get, + dispose: dispose + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + * @author bhouston / https://clara.io + * @author WestLangley / http://github.com/WestLangley + * + * parameters = { + * + * opacity: , + * + * map: new THREE.Texture( ), + * + * alphaMap: new THREE.Texture( ), + * + * displacementMap: new THREE.Texture( ), + * displacementScale: , + * displacementBias: , + * + * wireframe: , + * wireframeLinewidth: + * } + */ + + function MeshDepthMaterial( parameters ) { + + Material.call( this ); + + this.type = 'MeshDepthMaterial'; + + this.depthPacking = BasicDepthPacking; + + this.skinning = false; + this.morphTargets = false; + + this.map = null; + + this.alphaMap = null; + + this.displacementMap = null; + this.displacementScale = 1; + this.displacementBias = 0; + + this.wireframe = false; + this.wireframeLinewidth = 1; + + this.fog = false; + this.lights = false; + + this.setValues( parameters ); + + } + + MeshDepthMaterial.prototype = Object.create( Material.prototype ); + MeshDepthMaterial.prototype.constructor = MeshDepthMaterial; + + MeshDepthMaterial.prototype.isMeshDepthMaterial = true; + + MeshDepthMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.depthPacking = source.depthPacking; + + this.skinning = source.skinning; + this.morphTargets = source.morphTargets; + + this.map = source.map; + + this.alphaMap = source.alphaMap; + + this.displacementMap = source.displacementMap; + this.displacementScale = source.displacementScale; + this.displacementBias = source.displacementBias; + + this.wireframe = source.wireframe; + this.wireframeLinewidth = source.wireframeLinewidth; + + return this; + + }; + + /** + * @author WestLangley / http://github.com/WestLangley + * + * parameters = { + * + * referencePosition: , + * nearDistance: , + * farDistance: , + * + * skinning: , + * morphTargets: , + * + * map: new THREE.Texture( ), + * + * alphaMap: new THREE.Texture( ), + * + * displacementMap: new THREE.Texture( ), + * displacementScale: , + * displacementBias: + * + * } + */ + + function MeshDistanceMaterial( parameters ) { + + Material.call( this ); + + this.type = 'MeshDistanceMaterial'; + + this.referencePosition = new Vector3(); + this.nearDistance = 1; + this.farDistance = 1000; + + this.skinning = false; + this.morphTargets = false; + + this.map = null; + + this.alphaMap = null; + + this.displacementMap = null; + this.displacementScale = 1; + this.displacementBias = 0; + + this.fog = false; + this.lights = false; + + this.setValues( parameters ); + + } + + MeshDistanceMaterial.prototype = Object.create( Material.prototype ); + MeshDistanceMaterial.prototype.constructor = MeshDistanceMaterial; + + MeshDistanceMaterial.prototype.isMeshDistanceMaterial = true; + + MeshDistanceMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.referencePosition.copy( source.referencePosition ); + this.nearDistance = source.nearDistance; + this.farDistance = source.farDistance; + + this.skinning = source.skinning; + this.morphTargets = source.morphTargets; + + this.map = source.map; + + this.alphaMap = source.alphaMap; + + this.displacementMap = source.displacementMap; + this.displacementScale = source.displacementScale; + this.displacementBias = source.displacementBias; + + return this; + + }; + + /** + * @author alteredq / http://alteredqualia.com/ + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLShadowMap( _renderer, _objects, maxTextureSize ) { + + var _frustum = new Frustum(), + _projScreenMatrix = new Matrix4(), + + _shadowMapSize = new Vector2(), + _maxShadowMapSize = new Vector2( maxTextureSize, maxTextureSize ), + + _lookTarget = new Vector3(), + _lightPositionWorld = new Vector3(), + + _MorphingFlag = 1, + _SkinningFlag = 2, + + _NumberOfMaterialVariants = ( _MorphingFlag | _SkinningFlag ) + 1, + + _depthMaterials = new Array( _NumberOfMaterialVariants ), + _distanceMaterials = new Array( _NumberOfMaterialVariants ), + + _materialCache = {}; + + var shadowSide = { 0: BackSide, 1: FrontSide, 2: DoubleSide }; + + var cubeDirections = [ + new Vector3( 1, 0, 0 ), new Vector3( - 1, 0, 0 ), new Vector3( 0, 0, 1 ), + new Vector3( 0, 0, - 1 ), new Vector3( 0, 1, 0 ), new Vector3( 0, - 1, 0 ) + ]; + + var cubeUps = [ + new Vector3( 0, 1, 0 ), new Vector3( 0, 1, 0 ), new Vector3( 0, 1, 0 ), + new Vector3( 0, 1, 0 ), new Vector3( 0, 0, 1 ), new Vector3( 0, 0, - 1 ) + ]; + + var cube2DViewPorts = [ + new Vector4(), new Vector4(), new Vector4(), + new Vector4(), new Vector4(), new Vector4() + ]; + + // init + + for ( var i = 0; i !== _NumberOfMaterialVariants; ++ i ) { + + var useMorphing = ( i & _MorphingFlag ) !== 0; + var useSkinning = ( i & _SkinningFlag ) !== 0; + + var depthMaterial = new MeshDepthMaterial( { + + depthPacking: RGBADepthPacking, + + morphTargets: useMorphing, + skinning: useSkinning + + } ); + + _depthMaterials[ i ] = depthMaterial; + + // + + var distanceMaterial = new MeshDistanceMaterial( { + + morphTargets: useMorphing, + skinning: useSkinning + + } ); + + _distanceMaterials[ i ] = distanceMaterial; + + } + + // + + var scope = this; + + this.enabled = false; + + this.autoUpdate = true; + this.needsUpdate = false; + + this.type = PCFShadowMap; + + this.render = function ( lights, scene, camera ) { + + if ( scope.enabled === false ) return; + if ( scope.autoUpdate === false && scope.needsUpdate === false ) return; + + if ( lights.length === 0 ) return; + + // TODO Clean up (needed in case of contextlost) + var _gl = _renderer.context; + var _state = _renderer.state; + + // Set GL state for depth map. + _state.disable( _gl.BLEND ); + _state.buffers.color.setClear( 1, 1, 1, 1 ); + _state.buffers.depth.setTest( true ); + _state.setScissorTest( false ); + + // render depth map + + var faceCount; + + for ( var i = 0, il = lights.length; i < il; i ++ ) { + + var light = lights[ i ]; + var shadow = light.shadow; + var isPointLight = light && light.isPointLight; + + if ( shadow === undefined ) { + + console.warn( 'THREE.WebGLShadowMap:', light, 'has no shadow.' ); + continue; + + } + + var shadowCamera = shadow.camera; + + _shadowMapSize.copy( shadow.mapSize ); + _shadowMapSize.min( _maxShadowMapSize ); + + if ( isPointLight ) { + + var vpWidth = _shadowMapSize.x; + var vpHeight = _shadowMapSize.y; + + // These viewports map a cube-map onto a 2D texture with the + // following orientation: + // + // xzXZ + // y Y + // + // X - Positive x direction + // x - Negative x direction + // Y - Positive y direction + // y - Negative y direction + // Z - Positive z direction + // z - Negative z direction + + // positive X + cube2DViewPorts[ 0 ].set( vpWidth * 2, vpHeight, vpWidth, vpHeight ); + // negative X + cube2DViewPorts[ 1 ].set( 0, vpHeight, vpWidth, vpHeight ); + // positive Z + cube2DViewPorts[ 2 ].set( vpWidth * 3, vpHeight, vpWidth, vpHeight ); + // negative Z + cube2DViewPorts[ 3 ].set( vpWidth, vpHeight, vpWidth, vpHeight ); + // positive Y + cube2DViewPorts[ 4 ].set( vpWidth * 3, 0, vpWidth, vpHeight ); + // negative Y + cube2DViewPorts[ 5 ].set( vpWidth, 0, vpWidth, vpHeight ); + + _shadowMapSize.x *= 4.0; + _shadowMapSize.y *= 2.0; + + } + + if ( shadow.map === null ) { + + var pars = { minFilter: NearestFilter, magFilter: NearestFilter, format: RGBAFormat }; + + shadow.map = new WebGLRenderTarget( _shadowMapSize.x, _shadowMapSize.y, pars ); + shadow.map.texture.name = light.name + ".shadowMap"; + + shadowCamera.updateProjectionMatrix(); + + } + + if ( shadow.isSpotLightShadow ) { + + shadow.update( light ); + + } + + var shadowMap = shadow.map; + var shadowMatrix = shadow.matrix; + + _lightPositionWorld.setFromMatrixPosition( light.matrixWorld ); + shadowCamera.position.copy( _lightPositionWorld ); + + if ( isPointLight ) { + + faceCount = 6; + + // for point lights we set the shadow matrix to be a translation-only matrix + // equal to inverse of the light's position + + shadowMatrix.makeTranslation( - _lightPositionWorld.x, - _lightPositionWorld.y, - _lightPositionWorld.z ); + + } else { + + faceCount = 1; + + _lookTarget.setFromMatrixPosition( light.target.matrixWorld ); + shadowCamera.lookAt( _lookTarget ); + shadowCamera.updateMatrixWorld(); + + // compute shadow matrix + + shadowMatrix.set( + 0.5, 0.0, 0.0, 0.5, + 0.0, 0.5, 0.0, 0.5, + 0.0, 0.0, 0.5, 0.5, + 0.0, 0.0, 0.0, 1.0 + ); + + shadowMatrix.multiply( shadowCamera.projectionMatrix ); + shadowMatrix.multiply( shadowCamera.matrixWorldInverse ); + + } + + _renderer.setRenderTarget( shadowMap ); + _renderer.clear(); + + // render shadow map for each cube face (if omni-directional) or + // run a single pass if not + + for ( var face = 0; face < faceCount; face ++ ) { + + if ( isPointLight ) { + + _lookTarget.copy( shadowCamera.position ); + _lookTarget.add( cubeDirections[ face ] ); + shadowCamera.up.copy( cubeUps[ face ] ); + shadowCamera.lookAt( _lookTarget ); + shadowCamera.updateMatrixWorld(); + + var vpDimensions = cube2DViewPorts[ face ]; + _state.viewport( vpDimensions ); + + } + + // update camera matrices and frustum + + _projScreenMatrix.multiplyMatrices( shadowCamera.projectionMatrix, shadowCamera.matrixWorldInverse ); + _frustum.setFromMatrix( _projScreenMatrix ); + + // set object matrices & frustum culling + + renderObject( scene, camera, shadowCamera, isPointLight ); + + } + + } + + scope.needsUpdate = false; + + }; + + function getDepthMaterial( object, material, isPointLight, lightPositionWorld, shadowCameraNear, shadowCameraFar ) { + + var geometry = object.geometry; + + var result = null; + + var materialVariants = _depthMaterials; + var customMaterial = object.customDepthMaterial; + + if ( isPointLight ) { + + materialVariants = _distanceMaterials; + customMaterial = object.customDistanceMaterial; + + } + + if ( ! customMaterial ) { + + var useMorphing = false; + + if ( material.morphTargets ) { + + if ( geometry && geometry.isBufferGeometry ) { + + useMorphing = geometry.morphAttributes && geometry.morphAttributes.position && geometry.morphAttributes.position.length > 0; + + } else if ( geometry && geometry.isGeometry ) { + + useMorphing = geometry.morphTargets && geometry.morphTargets.length > 0; + + } + + } + + if ( object.isSkinnedMesh && material.skinning === false ) { + + console.warn( 'THREE.WebGLShadowMap: THREE.SkinnedMesh with material.skinning set to false:', object ); + + } + + var useSkinning = object.isSkinnedMesh && material.skinning; + + var variantIndex = 0; + + if ( useMorphing ) variantIndex |= _MorphingFlag; + if ( useSkinning ) variantIndex |= _SkinningFlag; + + result = materialVariants[ variantIndex ]; + + } else { + + result = customMaterial; + + } + + if ( _renderer.localClippingEnabled && + material.clipShadows === true && + material.clippingPlanes.length !== 0 ) { + + // in this case we need a unique material instance reflecting the + // appropriate state + + var keyA = result.uuid, keyB = material.uuid; + + var materialsForVariant = _materialCache[ keyA ]; + + if ( materialsForVariant === undefined ) { + + materialsForVariant = {}; + _materialCache[ keyA ] = materialsForVariant; + + } + + var cachedMaterial = materialsForVariant[ keyB ]; + + if ( cachedMaterial === undefined ) { + + cachedMaterial = result.clone(); + materialsForVariant[ keyB ] = cachedMaterial; + + } + + result = cachedMaterial; + + } + + result.visible = material.visible; + result.wireframe = material.wireframe; + + result.side = ( material.shadowSide != null ) ? material.shadowSide : shadowSide[ material.side ]; + + result.clipShadows = material.clipShadows; + result.clippingPlanes = material.clippingPlanes; + result.clipIntersection = material.clipIntersection; + + result.wireframeLinewidth = material.wireframeLinewidth; + result.linewidth = material.linewidth; + + if ( isPointLight && result.isMeshDistanceMaterial ) { + + result.referencePosition.copy( lightPositionWorld ); + result.nearDistance = shadowCameraNear; + result.farDistance = shadowCameraFar; + + } + + return result; + + } + + function renderObject( object, camera, shadowCamera, isPointLight ) { + + if ( object.visible === false ) return; + + var visible = object.layers.test( camera.layers ); + + if ( visible && ( object.isMesh || object.isLine || object.isPoints ) ) { + + if ( object.castShadow && ( ! object.frustumCulled || _frustum.intersectsObject( object ) ) ) { + + object.modelViewMatrix.multiplyMatrices( shadowCamera.matrixWorldInverse, object.matrixWorld ); + + var geometry = _objects.update( object ); + var material = object.material; + + if ( Array.isArray( material ) ) { + + var groups = geometry.groups; + + for ( var k = 0, kl = groups.length; k < kl; k ++ ) { + + var group = groups[ k ]; + var groupMaterial = material[ group.materialIndex ]; + + if ( groupMaterial && groupMaterial.visible ) { + + var depthMaterial = getDepthMaterial( object, groupMaterial, isPointLight, _lightPositionWorld, shadowCamera.near, shadowCamera.far ); + _renderer.renderBufferDirect( shadowCamera, null, geometry, depthMaterial, object, group ); + + } + + } + + } else if ( material.visible ) { + + var depthMaterial = getDepthMaterial( object, material, isPointLight, _lightPositionWorld, shadowCamera.near, shadowCamera.far ); + _renderer.renderBufferDirect( shadowCamera, null, geometry, depthMaterial, object, null ); + + } + + } + + } + + var children = object.children; + + for ( var i = 0, l = children.length; i < l; i ++ ) { + + renderObject( children[ i ], camera, shadowCamera, isPointLight ); + + } + + } + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function CanvasTexture( canvas, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy ) { + + Texture.call( this, canvas, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy ); + + this.needsUpdate = true; + + } + + CanvasTexture.prototype = Object.create( Texture.prototype ); + CanvasTexture.prototype.constructor = CanvasTexture; + CanvasTexture.prototype.isCanvasTexture = true; + + /** + * @author mikael emtinger / http://gomo.se/ + * @author alteredq / http://alteredqualia.com/ + */ + + function WebGLSpriteRenderer( renderer, gl, state, textures, capabilities ) { + + var vertexBuffer, elementBuffer; + var program, attributes, uniforms; + + var texture; + + // decompose matrixWorld + + var spritePosition = new Vector3(); + var spriteRotation = new Quaternion(); + var spriteScale = new Vector3(); + + function init() { + + var vertices = new Float32Array( [ + - 0.5, - 0.5, 0, 0, + 0.5, - 0.5, 1, 0, + 0.5, 0.5, 1, 1, + - 0.5, 0.5, 0, 1 + ] ); + + var faces = new Uint16Array( [ + 0, 1, 2, + 0, 2, 3 + ] ); + + vertexBuffer = gl.createBuffer(); + elementBuffer = gl.createBuffer(); + + gl.bindBuffer( gl.ARRAY_BUFFER, vertexBuffer ); + gl.bufferData( gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW ); + + gl.bindBuffer( gl.ELEMENT_ARRAY_BUFFER, elementBuffer ); + gl.bufferData( gl.ELEMENT_ARRAY_BUFFER, faces, gl.STATIC_DRAW ); + + program = createProgram(); + + attributes = { + position: gl.getAttribLocation( program, 'position' ), + uv: gl.getAttribLocation( program, 'uv' ) + }; + + uniforms = { + uvOffset: gl.getUniformLocation( program, 'uvOffset' ), + uvScale: gl.getUniformLocation( program, 'uvScale' ), + + rotation: gl.getUniformLocation( program, 'rotation' ), + center: gl.getUniformLocation( program, 'center' ), + scale: gl.getUniformLocation( program, 'scale' ), + + color: gl.getUniformLocation( program, 'color' ), + map: gl.getUniformLocation( program, 'map' ), + opacity: gl.getUniformLocation( program, 'opacity' ), + + modelViewMatrix: gl.getUniformLocation( program, 'modelViewMatrix' ), + projectionMatrix: gl.getUniformLocation( program, 'projectionMatrix' ), + + fogType: gl.getUniformLocation( program, 'fogType' ), + fogDensity: gl.getUniformLocation( program, 'fogDensity' ), + fogNear: gl.getUniformLocation( program, 'fogNear' ), + fogFar: gl.getUniformLocation( program, 'fogFar' ), + fogColor: gl.getUniformLocation( program, 'fogColor' ), + fogDepth: gl.getUniformLocation( program, 'fogDepth' ), + + alphaTest: gl.getUniformLocation( program, 'alphaTest' ) + }; + + var canvas = document.createElementNS( 'http://www.w3.org/1999/xhtml', 'canvas' ); + canvas.width = 8; + canvas.height = 8; + + var context = canvas.getContext( '2d' ); + context.fillStyle = 'white'; + context.fillRect( 0, 0, 8, 8 ); + + texture = new CanvasTexture( canvas ); + + } + + this.render = function ( sprites, scene, camera ) { + + if ( sprites.length === 0 ) return; + + // setup gl + + if ( program === undefined ) { + + init(); + + } + + state.useProgram( program ); + + state.initAttributes(); + state.enableAttribute( attributes.position ); + state.enableAttribute( attributes.uv ); + state.disableUnusedAttributes(); + + state.disable( gl.CULL_FACE ); + state.enable( gl.BLEND ); + + gl.bindBuffer( gl.ARRAY_BUFFER, vertexBuffer ); + gl.vertexAttribPointer( attributes.position, 2, gl.FLOAT, false, 2 * 8, 0 ); + gl.vertexAttribPointer( attributes.uv, 2, gl.FLOAT, false, 2 * 8, 8 ); + + gl.bindBuffer( gl.ELEMENT_ARRAY_BUFFER, elementBuffer ); + + gl.uniformMatrix4fv( uniforms.projectionMatrix, false, camera.projectionMatrix.elements ); + + state.activeTexture( gl.TEXTURE0 ); + gl.uniform1i( uniforms.map, 0 ); + + var oldFogType = 0; + var sceneFogType = 0; + var fog = scene.fog; + + if ( fog ) { + + gl.uniform3f( uniforms.fogColor, fog.color.r, fog.color.g, fog.color.b ); + + if ( fog.isFog ) { + + gl.uniform1f( uniforms.fogNear, fog.near ); + gl.uniform1f( uniforms.fogFar, fog.far ); + + gl.uniform1i( uniforms.fogType, 1 ); + oldFogType = 1; + sceneFogType = 1; + + } else if ( fog.isFogExp2 ) { + + gl.uniform1f( uniforms.fogDensity, fog.density ); + + gl.uniform1i( uniforms.fogType, 2 ); + oldFogType = 2; + sceneFogType = 2; + + } + + } else { + + gl.uniform1i( uniforms.fogType, 0 ); + oldFogType = 0; + sceneFogType = 0; + + } + + + // update positions and sort + + for ( var i = 0, l = sprites.length; i < l; i ++ ) { + + var sprite = sprites[ i ]; + + sprite.modelViewMatrix.multiplyMatrices( camera.matrixWorldInverse, sprite.matrixWorld ); + sprite.z = - sprite.modelViewMatrix.elements[ 14 ]; + + } + + sprites.sort( painterSortStable ); + + // render all sprites + + var scale = []; + var center = []; + + for ( var i = 0, l = sprites.length; i < l; i ++ ) { + + var sprite = sprites[ i ]; + var material = sprite.material; + + if ( material.visible === false ) continue; + + sprite.onBeforeRender( renderer, scene, camera, undefined, material, undefined ); + + gl.uniform1f( uniforms.alphaTest, material.alphaTest ); + gl.uniformMatrix4fv( uniforms.modelViewMatrix, false, sprite.modelViewMatrix.elements ); + + sprite.matrixWorld.decompose( spritePosition, spriteRotation, spriteScale ); + + scale[ 0 ] = spriteScale.x; + scale[ 1 ] = spriteScale.y; + + center[ 0 ] = sprite.center.x - 0.5; + center[ 1 ] = sprite.center.y - 0.5; + + var fogType = 0; + + if ( scene.fog && material.fog ) { + + fogType = sceneFogType; + + } + + if ( oldFogType !== fogType ) { + + gl.uniform1i( uniforms.fogType, fogType ); + oldFogType = fogType; + + } + + if ( material.map !== null ) { + + gl.uniform2f( uniforms.uvOffset, material.map.offset.x, material.map.offset.y ); + gl.uniform2f( uniforms.uvScale, material.map.repeat.x, material.map.repeat.y ); + + } else { + + gl.uniform2f( uniforms.uvOffset, 0, 0 ); + gl.uniform2f( uniforms.uvScale, 1, 1 ); + + } + + gl.uniform1f( uniforms.opacity, material.opacity ); + gl.uniform3f( uniforms.color, material.color.r, material.color.g, material.color.b ); + + gl.uniform1f( uniforms.rotation, material.rotation ); + gl.uniform2fv( uniforms.center, center ); + gl.uniform2fv( uniforms.scale, scale ); + + state.setBlending( material.blending, material.blendEquation, material.blendSrc, material.blendDst, material.blendEquationAlpha, material.blendSrcAlpha, material.blendDstAlpha, material.premultipliedAlpha ); + state.buffers.depth.setTest( material.depthTest ); + state.buffers.depth.setMask( material.depthWrite ); + state.buffers.color.setMask( material.colorWrite ); + + textures.setTexture2D( material.map || texture, 0 ); + + gl.drawElements( gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0 ); + + sprite.onAfterRender( renderer, scene, camera, undefined, material, undefined ); + + } + + // restore gl + + state.enable( gl.CULL_FACE ); + + state.reset(); + + }; + + function createProgram() { + + var program = gl.createProgram(); + + var vertexShader = gl.createShader( gl.VERTEX_SHADER ); + var fragmentShader = gl.createShader( gl.FRAGMENT_SHADER ); + + gl.shaderSource( vertexShader, [ + + 'precision ' + capabilities.precision + ' float;', + + '#define SHADER_NAME ' + 'SpriteMaterial', + + 'uniform mat4 modelViewMatrix;', + 'uniform mat4 projectionMatrix;', + 'uniform float rotation;', + 'uniform vec2 center;', + 'uniform vec2 scale;', + 'uniform vec2 uvOffset;', + 'uniform vec2 uvScale;', + + 'attribute vec2 position;', + 'attribute vec2 uv;', + + 'varying vec2 vUV;', + 'varying float fogDepth;', + + 'void main() {', + + ' vUV = uvOffset + uv * uvScale;', + + ' vec2 alignedPosition = ( position - center ) * scale;', + + ' vec2 rotatedPosition;', + ' rotatedPosition.x = cos( rotation ) * alignedPosition.x - sin( rotation ) * alignedPosition.y;', + ' rotatedPosition.y = sin( rotation ) * alignedPosition.x + cos( rotation ) * alignedPosition.y;', + + ' vec4 mvPosition;', + + ' mvPosition = modelViewMatrix * vec4( 0.0, 0.0, 0.0, 1.0 );', + ' mvPosition.xy += rotatedPosition;', + + ' gl_Position = projectionMatrix * mvPosition;', + + ' fogDepth = - mvPosition.z;', + + '}' + + ].join( '\n' ) ); + + gl.shaderSource( fragmentShader, [ + + 'precision ' + capabilities.precision + ' float;', + + '#define SHADER_NAME ' + 'SpriteMaterial', + + 'uniform vec3 color;', + 'uniform sampler2D map;', + 'uniform float opacity;', + + 'uniform int fogType;', + 'uniform vec3 fogColor;', + 'uniform float fogDensity;', + 'uniform float fogNear;', + 'uniform float fogFar;', + 'uniform float alphaTest;', + + 'varying vec2 vUV;', + 'varying float fogDepth;', + + 'void main() {', + + ' vec4 texture = texture2D( map, vUV );', + + ' gl_FragColor = vec4( color * texture.xyz, texture.a * opacity );', + + ' if ( gl_FragColor.a < alphaTest ) discard;', + + ' if ( fogType > 0 ) {', + + ' float fogFactor = 0.0;', + + ' if ( fogType == 1 ) {', + + ' fogFactor = smoothstep( fogNear, fogFar, fogDepth );', + + ' } else {', + + ' const float LOG2 = 1.442695;', + ' fogFactor = exp2( - fogDensity * fogDensity * fogDepth * fogDepth * LOG2 );', + ' fogFactor = 1.0 - clamp( fogFactor, 0.0, 1.0 );', + + ' }', + + ' gl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );', + + ' }', + + '}' + + ].join( '\n' ) ); + + gl.compileShader( vertexShader ); + gl.compileShader( fragmentShader ); + + gl.attachShader( program, vertexShader ); + gl.attachShader( program, fragmentShader ); + + gl.linkProgram( program ); + + return program; + + } + + function painterSortStable( a, b ) { + + if ( a.renderOrder !== b.renderOrder ) { + + return a.renderOrder - b.renderOrder; + + } else if ( a.z !== b.z ) { + + return b.z - a.z; + + } else { + + return b.id - a.id; + + } + + } + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLState( gl, extensions, utils ) { + + function ColorBuffer() { + + var locked = false; + + var color = new Vector4(); + var currentColorMask = null; + var currentColorClear = new Vector4( 0, 0, 0, 0 ); + + return { + + setMask: function ( colorMask ) { + + if ( currentColorMask !== colorMask && ! locked ) { + + gl.colorMask( colorMask, colorMask, colorMask, colorMask ); + currentColorMask = colorMask; + + } + + }, + + setLocked: function ( lock ) { + + locked = lock; + + }, + + setClear: function ( r, g, b, a, premultipliedAlpha ) { + + if ( premultipliedAlpha === true ) { + + r *= a; g *= a; b *= a; + + } + + color.set( r, g, b, a ); + + if ( currentColorClear.equals( color ) === false ) { + + gl.clearColor( r, g, b, a ); + currentColorClear.copy( color ); + + } + + }, + + reset: function () { + + locked = false; + + currentColorMask = null; + currentColorClear.set( - 1, 0, 0, 0 ); // set to invalid state + + } + + }; + + } + + function DepthBuffer() { + + var locked = false; + + var currentDepthMask = null; + var currentDepthFunc = null; + var currentDepthClear = null; + + return { + + setTest: function ( depthTest ) { + + if ( depthTest ) { + + enable( gl.DEPTH_TEST ); + + } else { + + disable( gl.DEPTH_TEST ); + + } + + }, + + setMask: function ( depthMask ) { + + if ( currentDepthMask !== depthMask && ! locked ) { + + gl.depthMask( depthMask ); + currentDepthMask = depthMask; + + } + + }, + + setFunc: function ( depthFunc ) { + + if ( currentDepthFunc !== depthFunc ) { + + if ( depthFunc ) { + + switch ( depthFunc ) { + + case NeverDepth: + + gl.depthFunc( gl.NEVER ); + break; + + case AlwaysDepth: + + gl.depthFunc( gl.ALWAYS ); + break; + + case LessDepth: + + gl.depthFunc( gl.LESS ); + break; + + case LessEqualDepth: + + gl.depthFunc( gl.LEQUAL ); + break; + + case EqualDepth: + + gl.depthFunc( gl.EQUAL ); + break; + + case GreaterEqualDepth: + + gl.depthFunc( gl.GEQUAL ); + break; + + case GreaterDepth: + + gl.depthFunc( gl.GREATER ); + break; + + case NotEqualDepth: + + gl.depthFunc( gl.NOTEQUAL ); + break; + + default: + + gl.depthFunc( gl.LEQUAL ); + + } + + } else { + + gl.depthFunc( gl.LEQUAL ); + + } + + currentDepthFunc = depthFunc; + + } + + }, + + setLocked: function ( lock ) { + + locked = lock; + + }, + + setClear: function ( depth ) { + + if ( currentDepthClear !== depth ) { + + gl.clearDepth( depth ); + currentDepthClear = depth; + + } + + }, + + reset: function () { + + locked = false; + + currentDepthMask = null; + currentDepthFunc = null; + currentDepthClear = null; + + } + + }; + + } + + function StencilBuffer() { + + var locked = false; + + var currentStencilMask = null; + var currentStencilFunc = null; + var currentStencilRef = null; + var currentStencilFuncMask = null; + var currentStencilFail = null; + var currentStencilZFail = null; + var currentStencilZPass = null; + var currentStencilClear = null; + + return { + + setTest: function ( stencilTest ) { + + if ( stencilTest ) { + + enable( gl.STENCIL_TEST ); + + } else { + + disable( gl.STENCIL_TEST ); + + } + + }, + + setMask: function ( stencilMask ) { + + if ( currentStencilMask !== stencilMask && ! locked ) { + + gl.stencilMask( stencilMask ); + currentStencilMask = stencilMask; + + } + + }, + + setFunc: function ( stencilFunc, stencilRef, stencilMask ) { + + if ( currentStencilFunc !== stencilFunc || + currentStencilRef !== stencilRef || + currentStencilFuncMask !== stencilMask ) { + + gl.stencilFunc( stencilFunc, stencilRef, stencilMask ); + + currentStencilFunc = stencilFunc; + currentStencilRef = stencilRef; + currentStencilFuncMask = stencilMask; + + } + + }, + + setOp: function ( stencilFail, stencilZFail, stencilZPass ) { + + if ( currentStencilFail !== stencilFail || + currentStencilZFail !== stencilZFail || + currentStencilZPass !== stencilZPass ) { + + gl.stencilOp( stencilFail, stencilZFail, stencilZPass ); + + currentStencilFail = stencilFail; + currentStencilZFail = stencilZFail; + currentStencilZPass = stencilZPass; + + } + + }, + + setLocked: function ( lock ) { + + locked = lock; + + }, + + setClear: function ( stencil ) { + + if ( currentStencilClear !== stencil ) { + + gl.clearStencil( stencil ); + currentStencilClear = stencil; + + } + + }, + + reset: function () { + + locked = false; + + currentStencilMask = null; + currentStencilFunc = null; + currentStencilRef = null; + currentStencilFuncMask = null; + currentStencilFail = null; + currentStencilZFail = null; + currentStencilZPass = null; + currentStencilClear = null; + + } + + }; + + } + + // + + var colorBuffer = new ColorBuffer(); + var depthBuffer = new DepthBuffer(); + var stencilBuffer = new StencilBuffer(); + + var maxVertexAttributes = gl.getParameter( gl.MAX_VERTEX_ATTRIBS ); + var newAttributes = new Uint8Array( maxVertexAttributes ); + var enabledAttributes = new Uint8Array( maxVertexAttributes ); + var attributeDivisors = new Uint8Array( maxVertexAttributes ); + + var capabilities = {}; + + var compressedTextureFormats = null; + + var currentProgram = null; + + var currentBlending = null; + var currentBlendEquation = null; + var currentBlendSrc = null; + var currentBlendDst = null; + var currentBlendEquationAlpha = null; + var currentBlendSrcAlpha = null; + var currentBlendDstAlpha = null; + var currentPremultipledAlpha = false; + + var currentFlipSided = null; + var currentCullFace = null; + + var currentLineWidth = null; + + var currentPolygonOffsetFactor = null; + var currentPolygonOffsetUnits = null; + + var maxTextures = gl.getParameter( gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS ); + + var lineWidthAvailable = false; + var version = 0; + var glVersion = gl.getParameter( gl.VERSION ); + + if ( glVersion.indexOf( 'WebGL' ) !== - 1 ) { + + version = parseFloat( /^WebGL\ ([0-9])/.exec( glVersion )[ 1 ] ); + lineWidthAvailable = ( version >= 1.0 ); + + } else if ( glVersion.indexOf( 'OpenGL ES' ) !== - 1 ) { + + version = parseFloat( /^OpenGL\ ES\ ([0-9])/.exec( glVersion )[ 1 ] ); + lineWidthAvailable = ( version >= 2.0 ); + + } + + var currentTextureSlot = null; + var currentBoundTextures = {}; + + var currentScissor = new Vector4(); + var currentViewport = new Vector4(); + + function createTexture( type, target, count ) { + + var data = new Uint8Array( 4 ); // 4 is required to match default unpack alignment of 4. + var texture = gl.createTexture(); + + gl.bindTexture( type, texture ); + gl.texParameteri( type, gl.TEXTURE_MIN_FILTER, gl.NEAREST ); + gl.texParameteri( type, gl.TEXTURE_MAG_FILTER, gl.NEAREST ); + + for ( var i = 0; i < count; i ++ ) { + + gl.texImage2D( target + i, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, data ); + + } + + return texture; + + } + + var emptyTextures = {}; + emptyTextures[ gl.TEXTURE_2D ] = createTexture( gl.TEXTURE_2D, gl.TEXTURE_2D, 1 ); + emptyTextures[ gl.TEXTURE_CUBE_MAP ] = createTexture( gl.TEXTURE_CUBE_MAP, gl.TEXTURE_CUBE_MAP_POSITIVE_X, 6 ); + + // init + + colorBuffer.setClear( 0, 0, 0, 1 ); + depthBuffer.setClear( 1 ); + stencilBuffer.setClear( 0 ); + + enable( gl.DEPTH_TEST ); + depthBuffer.setFunc( LessEqualDepth ); + + setFlipSided( false ); + setCullFace( CullFaceBack ); + enable( gl.CULL_FACE ); + + enable( gl.BLEND ); + setBlending( NormalBlending ); + + // + + function initAttributes() { + + for ( var i = 0, l = newAttributes.length; i < l; i ++ ) { + + newAttributes[ i ] = 0; + + } + + } + + function enableAttribute( attribute ) { + + enableAttributeAndDivisor( attribute, 0 ); + + } + + function enableAttributeAndDivisor( attribute, meshPerAttribute ) { + + newAttributes[ attribute ] = 1; + + if ( enabledAttributes[ attribute ] === 0 ) { + + gl.enableVertexAttribArray( attribute ); + enabledAttributes[ attribute ] = 1; + + } + + if ( attributeDivisors[ attribute ] !== meshPerAttribute ) { + + var extension = extensions.get( 'ANGLE_instanced_arrays' ); + + extension.vertexAttribDivisorANGLE( attribute, meshPerAttribute ); + attributeDivisors[ attribute ] = meshPerAttribute; + + } + + } + + function disableUnusedAttributes() { + + for ( var i = 0, l = enabledAttributes.length; i !== l; ++ i ) { + + if ( enabledAttributes[ i ] !== newAttributes[ i ] ) { + + gl.disableVertexAttribArray( i ); + enabledAttributes[ i ] = 0; + + } + + } + + } + + function enable( id ) { + + if ( capabilities[ id ] !== true ) { + + gl.enable( id ); + capabilities[ id ] = true; + + } + + } + + function disable( id ) { + + if ( capabilities[ id ] !== false ) { + + gl.disable( id ); + capabilities[ id ] = false; + + } + + } + + function getCompressedTextureFormats() { + + if ( compressedTextureFormats === null ) { + + compressedTextureFormats = []; + + if ( extensions.get( 'WEBGL_compressed_texture_pvrtc' ) || + extensions.get( 'WEBGL_compressed_texture_s3tc' ) || + extensions.get( 'WEBGL_compressed_texture_etc1' ) || + extensions.get( 'WEBGL_compressed_texture_astc' ) ) { + + var formats = gl.getParameter( gl.COMPRESSED_TEXTURE_FORMATS ); + + for ( var i = 0; i < formats.length; i ++ ) { + + compressedTextureFormats.push( formats[ i ] ); + + } + + } + + } + + return compressedTextureFormats; + + } + + function useProgram( program ) { + + if ( currentProgram !== program ) { + + gl.useProgram( program ); + + currentProgram = program; + + return true; + + } + + return false; + + } + + function setBlending( blending, blendEquation, blendSrc, blendDst, blendEquationAlpha, blendSrcAlpha, blendDstAlpha, premultipliedAlpha ) { + + if ( blending !== NoBlending ) { + + enable( gl.BLEND ); + + } else { + + disable( gl.BLEND ); + + } + + if ( blending !== CustomBlending ) { + + if ( blending !== currentBlending || premultipliedAlpha !== currentPremultipledAlpha ) { + + switch ( blending ) { + + case AdditiveBlending: + + if ( premultipliedAlpha ) { + + gl.blendEquationSeparate( gl.FUNC_ADD, gl.FUNC_ADD ); + gl.blendFuncSeparate( gl.ONE, gl.ONE, gl.ONE, gl.ONE ); + + } else { + + gl.blendEquation( gl.FUNC_ADD ); + gl.blendFunc( gl.SRC_ALPHA, gl.ONE ); + + } + break; + + case SubtractiveBlending: + + if ( premultipliedAlpha ) { + + gl.blendEquationSeparate( gl.FUNC_ADD, gl.FUNC_ADD ); + gl.blendFuncSeparate( gl.ZERO, gl.ZERO, gl.ONE_MINUS_SRC_COLOR, gl.ONE_MINUS_SRC_ALPHA ); + + } else { + + gl.blendEquation( gl.FUNC_ADD ); + gl.blendFunc( gl.ZERO, gl.ONE_MINUS_SRC_COLOR ); + + } + break; + + case MultiplyBlending: + + if ( premultipliedAlpha ) { + + gl.blendEquationSeparate( gl.FUNC_ADD, gl.FUNC_ADD ); + gl.blendFuncSeparate( gl.ZERO, gl.SRC_COLOR, gl.ZERO, gl.SRC_ALPHA ); + + } else { + + gl.blendEquation( gl.FUNC_ADD ); + gl.blendFunc( gl.ZERO, gl.SRC_COLOR ); + + } + break; + + default: + + if ( premultipliedAlpha ) { + + gl.blendEquationSeparate( gl.FUNC_ADD, gl.FUNC_ADD ); + gl.blendFuncSeparate( gl.ONE, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA ); + + } else { + + gl.blendEquationSeparate( gl.FUNC_ADD, gl.FUNC_ADD ); + gl.blendFuncSeparate( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA ); + + } + + } + + } + + currentBlendEquation = null; + currentBlendSrc = null; + currentBlendDst = null; + currentBlendEquationAlpha = null; + currentBlendSrcAlpha = null; + currentBlendDstAlpha = null; + + } else { + + blendEquationAlpha = blendEquationAlpha || blendEquation; + blendSrcAlpha = blendSrcAlpha || blendSrc; + blendDstAlpha = blendDstAlpha || blendDst; + + if ( blendEquation !== currentBlendEquation || blendEquationAlpha !== currentBlendEquationAlpha ) { + + gl.blendEquationSeparate( utils.convert( blendEquation ), utils.convert( blendEquationAlpha ) ); + + currentBlendEquation = blendEquation; + currentBlendEquationAlpha = blendEquationAlpha; + + } + + if ( blendSrc !== currentBlendSrc || blendDst !== currentBlendDst || blendSrcAlpha !== currentBlendSrcAlpha || blendDstAlpha !== currentBlendDstAlpha ) { + + gl.blendFuncSeparate( utils.convert( blendSrc ), utils.convert( blendDst ), utils.convert( blendSrcAlpha ), utils.convert( blendDstAlpha ) ); + + currentBlendSrc = blendSrc; + currentBlendDst = blendDst; + currentBlendSrcAlpha = blendSrcAlpha; + currentBlendDstAlpha = blendDstAlpha; + + } + + } + + currentBlending = blending; + currentPremultipledAlpha = premultipliedAlpha; + + } + + function setMaterial( material, frontFaceCW ) { + + material.side === DoubleSide + ? disable( gl.CULL_FACE ) + : enable( gl.CULL_FACE ); + + var flipSided = ( material.side === BackSide ); + if ( frontFaceCW ) flipSided = ! flipSided; + + setFlipSided( flipSided ); + + material.transparent === true + ? setBlending( material.blending, material.blendEquation, material.blendSrc, material.blendDst, material.blendEquationAlpha, material.blendSrcAlpha, material.blendDstAlpha, material.premultipliedAlpha ) + : setBlending( NoBlending ); + + depthBuffer.setFunc( material.depthFunc ); + depthBuffer.setTest( material.depthTest ); + depthBuffer.setMask( material.depthWrite ); + colorBuffer.setMask( material.colorWrite ); + + setPolygonOffset( material.polygonOffset, material.polygonOffsetFactor, material.polygonOffsetUnits ); + + } + + // + + function setFlipSided( flipSided ) { + + if ( currentFlipSided !== flipSided ) { + + if ( flipSided ) { + + gl.frontFace( gl.CW ); + + } else { + + gl.frontFace( gl.CCW ); + + } + + currentFlipSided = flipSided; + + } + + } + + function setCullFace( cullFace ) { + + if ( cullFace !== CullFaceNone ) { + + enable( gl.CULL_FACE ); + + if ( cullFace !== currentCullFace ) { + + if ( cullFace === CullFaceBack ) { + + gl.cullFace( gl.BACK ); + + } else if ( cullFace === CullFaceFront ) { + + gl.cullFace( gl.FRONT ); + + } else { + + gl.cullFace( gl.FRONT_AND_BACK ); + + } + + } + + } else { + + disable( gl.CULL_FACE ); + + } + + currentCullFace = cullFace; + + } + + function setLineWidth( width ) { + + if ( width !== currentLineWidth ) { + + if ( lineWidthAvailable ) gl.lineWidth( width ); + + currentLineWidth = width; + + } + + } + + function setPolygonOffset( polygonOffset, factor, units ) { + + if ( polygonOffset ) { + + enable( gl.POLYGON_OFFSET_FILL ); + + if ( currentPolygonOffsetFactor !== factor || currentPolygonOffsetUnits !== units ) { + + gl.polygonOffset( factor, units ); + + currentPolygonOffsetFactor = factor; + currentPolygonOffsetUnits = units; + + } + + } else { + + disable( gl.POLYGON_OFFSET_FILL ); + + } + + } + + function setScissorTest( scissorTest ) { + + if ( scissorTest ) { + + enable( gl.SCISSOR_TEST ); + + } else { + + disable( gl.SCISSOR_TEST ); + + } + + } + + // texture + + function activeTexture( webglSlot ) { + + if ( webglSlot === undefined ) webglSlot = gl.TEXTURE0 + maxTextures - 1; + + if ( currentTextureSlot !== webglSlot ) { + + gl.activeTexture( webglSlot ); + currentTextureSlot = webglSlot; + + } + + } + + function bindTexture( webglType, webglTexture ) { + + if ( currentTextureSlot === null ) { + + activeTexture(); + + } + + var boundTexture = currentBoundTextures[ currentTextureSlot ]; + + if ( boundTexture === undefined ) { + + boundTexture = { type: undefined, texture: undefined }; + currentBoundTextures[ currentTextureSlot ] = boundTexture; + + } + + if ( boundTexture.type !== webglType || boundTexture.texture !== webglTexture ) { + + gl.bindTexture( webglType, webglTexture || emptyTextures[ webglType ] ); + + boundTexture.type = webglType; + boundTexture.texture = webglTexture; + + } + + } + + function compressedTexImage2D() { + + try { + + gl.compressedTexImage2D.apply( gl, arguments ); + + } catch ( error ) { + + console.error( 'THREE.WebGLState:', error ); + + } + + } + + function texImage2D() { + + try { + + gl.texImage2D.apply( gl, arguments ); + + } catch ( error ) { + + console.error( 'THREE.WebGLState:', error ); + + } + + } + + // + + function scissor( scissor ) { + + if ( currentScissor.equals( scissor ) === false ) { + + gl.scissor( scissor.x, scissor.y, scissor.z, scissor.w ); + currentScissor.copy( scissor ); + + } + + } + + function viewport( viewport ) { + + if ( currentViewport.equals( viewport ) === false ) { + + gl.viewport( viewport.x, viewport.y, viewport.z, viewport.w ); + currentViewport.copy( viewport ); + + } + + } + + // + + function reset() { + + for ( var i = 0; i < enabledAttributes.length; i ++ ) { + + if ( enabledAttributes[ i ] === 1 ) { + + gl.disableVertexAttribArray( i ); + enabledAttributes[ i ] = 0; + + } + + } + + capabilities = {}; + + compressedTextureFormats = null; + + currentTextureSlot = null; + currentBoundTextures = {}; + + currentProgram = null; + + currentBlending = null; + + currentFlipSided = null; + currentCullFace = null; + + colorBuffer.reset(); + depthBuffer.reset(); + stencilBuffer.reset(); + + } + + return { + + buffers: { + color: colorBuffer, + depth: depthBuffer, + stencil: stencilBuffer + }, + + initAttributes: initAttributes, + enableAttribute: enableAttribute, + enableAttributeAndDivisor: enableAttributeAndDivisor, + disableUnusedAttributes: disableUnusedAttributes, + enable: enable, + disable: disable, + getCompressedTextureFormats: getCompressedTextureFormats, + + useProgram: useProgram, + + setBlending: setBlending, + setMaterial: setMaterial, + + setFlipSided: setFlipSided, + setCullFace: setCullFace, + + setLineWidth: setLineWidth, + setPolygonOffset: setPolygonOffset, + + setScissorTest: setScissorTest, + + activeTexture: activeTexture, + bindTexture: bindTexture, + compressedTexImage2D: compressedTexImage2D, + texImage2D: texImage2D, + + scissor: scissor, + viewport: viewport, + + reset: reset + + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebGLTextures( _gl, extensions, state, properties, capabilities, utils, info ) { + + var _isWebGL2 = ( typeof WebGL2RenderingContext !== 'undefined' && _gl instanceof WebGL2RenderingContext ); /* global WebGL2RenderingContext */ + var _videoTextures = {}; + var _canvas; + + // + + function clampToMaxSize( image, maxSize ) { + + if ( image.width > maxSize || image.height > maxSize ) { + + if ( 'data' in image ) { + + console.warn( 'THREE.WebGLRenderer: image in DataTexture is too big (' + image.width + 'x' + image.height + ').' ); + return; + + } + + // Warning: Scaling through the canvas will only work with images that use + // premultiplied alpha. + + var scale = maxSize / Math.max( image.width, image.height ); + + var canvas = document.createElementNS( 'http://www.w3.org/1999/xhtml', 'canvas' ); + canvas.width = Math.floor( image.width * scale ); + canvas.height = Math.floor( image.height * scale ); + + var context = canvas.getContext( '2d' ); + context.drawImage( image, 0, 0, image.width, image.height, 0, 0, canvas.width, canvas.height ); + + console.warn( 'THREE.WebGLRenderer: image is too big (' + image.width + 'x' + image.height + '). Resized to ' + canvas.width + 'x' + canvas.height, image ); + + return canvas; + + } + + return image; + + } + + function isPowerOfTwo( image ) { + + return _Math.isPowerOfTwo( image.width ) && _Math.isPowerOfTwo( image.height ); + + } + + function makePowerOfTwo( image ) { + + if ( image instanceof HTMLImageElement || image instanceof HTMLCanvasElement || image instanceof ImageBitmap ) { + + if ( _canvas === undefined ) _canvas = document.createElementNS( 'http://www.w3.org/1999/xhtml', 'canvas' ); + + _canvas.width = _Math.floorPowerOfTwo( image.width ); + _canvas.height = _Math.floorPowerOfTwo( image.height ); + + var context = _canvas.getContext( '2d' ); + context.drawImage( image, 0, 0, _canvas.width, _canvas.height ); + + console.warn( 'THREE.WebGLRenderer: image is not power of two (' + image.width + 'x' + image.height + '). Resized to ' + _canvas.width + 'x' + _canvas.height, image ); + + return _canvas; + + } + + return image; + + } + + function textureNeedsPowerOfTwo( texture ) { + + return ( texture.wrapS !== ClampToEdgeWrapping || texture.wrapT !== ClampToEdgeWrapping ) || + ( texture.minFilter !== NearestFilter && texture.minFilter !== LinearFilter ); + + } + + function textureNeedsGenerateMipmaps( texture, isPowerOfTwo ) { + + return texture.generateMipmaps && isPowerOfTwo && + texture.minFilter !== NearestFilter && texture.minFilter !== LinearFilter; + + } + + function generateMipmap( target, texture, width, height ) { + + _gl.generateMipmap( target ); + + var textureProperties = properties.get( texture ); + + // Note: Math.log( x ) * Math.LOG2E used instead of Math.log2( x ) which is not supported by IE11 + textureProperties.__maxMipLevel = Math.log( Math.max( width, height ) ) * Math.LOG2E; + + } + + // Fallback filters for non-power-of-2 textures + + function filterFallback( f ) { + + if ( f === NearestFilter || f === NearestMipMapNearestFilter || f === NearestMipMapLinearFilter ) { + + return _gl.NEAREST; + + } + + return _gl.LINEAR; + + } + + // + + function onTextureDispose( event ) { + + var texture = event.target; + + texture.removeEventListener( 'dispose', onTextureDispose ); + + deallocateTexture( texture ); + + if ( texture.isVideoTexture ) { + + delete _videoTextures[ texture.id ]; + + } + + info.memory.textures --; + + } + + function onRenderTargetDispose( event ) { + + var renderTarget = event.target; + + renderTarget.removeEventListener( 'dispose', onRenderTargetDispose ); + + deallocateRenderTarget( renderTarget ); + + info.memory.textures --; + + } + + // + + function deallocateTexture( texture ) { + + var textureProperties = properties.get( texture ); + + if ( texture.image && textureProperties.__image__webglTextureCube ) { + + // cube texture + + _gl.deleteTexture( textureProperties.__image__webglTextureCube ); + + } else { + + // 2D texture + + if ( textureProperties.__webglInit === undefined ) return; + + _gl.deleteTexture( textureProperties.__webglTexture ); + + } + + // remove all webgl properties + properties.remove( texture ); + + } + + function deallocateRenderTarget( renderTarget ) { + + var renderTargetProperties = properties.get( renderTarget ); + var textureProperties = properties.get( renderTarget.texture ); + + if ( ! renderTarget ) return; + + if ( textureProperties.__webglTexture !== undefined ) { + + _gl.deleteTexture( textureProperties.__webglTexture ); + + } + + if ( renderTarget.depthTexture ) { + + renderTarget.depthTexture.dispose(); + + } + + if ( renderTarget.isWebGLRenderTargetCube ) { + + for ( var i = 0; i < 6; i ++ ) { + + _gl.deleteFramebuffer( renderTargetProperties.__webglFramebuffer[ i ] ); + if ( renderTargetProperties.__webglDepthbuffer ) _gl.deleteRenderbuffer( renderTargetProperties.__webglDepthbuffer[ i ] ); + + } + + } else { + + _gl.deleteFramebuffer( renderTargetProperties.__webglFramebuffer ); + if ( renderTargetProperties.__webglDepthbuffer ) _gl.deleteRenderbuffer( renderTargetProperties.__webglDepthbuffer ); + + } + + properties.remove( renderTarget.texture ); + properties.remove( renderTarget ); + + } + + // + + + + function setTexture2D( texture, slot ) { + + var textureProperties = properties.get( texture ); + + if ( texture.isVideoTexture ) updateVideoTexture( texture ); + + if ( texture.version > 0 && textureProperties.__version !== texture.version ) { + + var image = texture.image; + + if ( image === undefined ) { + + console.warn( 'THREE.WebGLRenderer: Texture marked for update but image is undefined', texture ); + + } else if ( image.complete === false ) { + + console.warn( 'THREE.WebGLRenderer: Texture marked for update but image is incomplete', texture ); + + } else { + + uploadTexture( textureProperties, texture, slot ); + return; + + } + + } + + state.activeTexture( _gl.TEXTURE0 + slot ); + state.bindTexture( _gl.TEXTURE_2D, textureProperties.__webglTexture ); + + } + + function setTextureCube( texture, slot ) { + + var textureProperties = properties.get( texture ); + + if ( texture.image.length === 6 ) { + + if ( texture.version > 0 && textureProperties.__version !== texture.version ) { + + if ( ! textureProperties.__image__webglTextureCube ) { + + texture.addEventListener( 'dispose', onTextureDispose ); + + textureProperties.__image__webglTextureCube = _gl.createTexture(); + + info.memory.textures ++; + + } + + state.activeTexture( _gl.TEXTURE0 + slot ); + state.bindTexture( _gl.TEXTURE_CUBE_MAP, textureProperties.__image__webglTextureCube ); + + _gl.pixelStorei( _gl.UNPACK_FLIP_Y_WEBGL, texture.flipY ); + + var isCompressed = ( texture && texture.isCompressedTexture ); + var isDataTexture = ( texture.image[ 0 ] && texture.image[ 0 ].isDataTexture ); + + var cubeImage = []; + + for ( var i = 0; i < 6; i ++ ) { + + if ( ! isCompressed && ! isDataTexture ) { + + cubeImage[ i ] = clampToMaxSize( texture.image[ i ], capabilities.maxCubemapSize ); + + } else { + + cubeImage[ i ] = isDataTexture ? texture.image[ i ].image : texture.image[ i ]; + + } + + } + + var image = cubeImage[ 0 ], + isPowerOfTwoImage = isPowerOfTwo( image ), + glFormat = utils.convert( texture.format ), + glType = utils.convert( texture.type ); + + setTextureParameters( _gl.TEXTURE_CUBE_MAP, texture, isPowerOfTwoImage ); + + for ( var i = 0; i < 6; i ++ ) { + + if ( ! isCompressed ) { + + if ( isDataTexture ) { + + state.texImage2D( _gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, glFormat, cubeImage[ i ].width, cubeImage[ i ].height, 0, glFormat, glType, cubeImage[ i ].data ); + + } else { + + state.texImage2D( _gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, glFormat, glFormat, glType, cubeImage[ i ] ); + + } + + } else { + + var mipmap, mipmaps = cubeImage[ i ].mipmaps; + + for ( var j = 0, jl = mipmaps.length; j < jl; j ++ ) { + + mipmap = mipmaps[ j ]; + + if ( texture.format !== RGBAFormat && texture.format !== RGBFormat ) { + + if ( state.getCompressedTextureFormats().indexOf( glFormat ) > - 1 ) { + + state.compressedTexImage2D( _gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, j, glFormat, mipmap.width, mipmap.height, 0, mipmap.data ); + + } else { + + console.warn( 'THREE.WebGLRenderer: Attempt to load unsupported compressed texture format in .setTextureCube()' ); + + } + + } else { + + state.texImage2D( _gl.TEXTURE_CUBE_MAP_POSITIVE_X + i, j, glFormat, mipmap.width, mipmap.height, 0, glFormat, glType, mipmap.data ); + + } + + } + + } + + } + + if ( ! isCompressed ) { + + textureProperties.__maxMipLevel = 0; + + } else { + + textureProperties.__maxMipLevel = mipmaps.length - 1; + + } + + if ( textureNeedsGenerateMipmaps( texture, isPowerOfTwoImage ) ) { + + // We assume images for cube map have the same size. + generateMipmap( _gl.TEXTURE_CUBE_MAP, texture, image.width, image.height ); + + } + + textureProperties.__version = texture.version; + + if ( texture.onUpdate ) texture.onUpdate( texture ); + + } else { + + state.activeTexture( _gl.TEXTURE0 + slot ); + state.bindTexture( _gl.TEXTURE_CUBE_MAP, textureProperties.__image__webglTextureCube ); + + } + + } + + } + + function setTextureCubeDynamic( texture, slot ) { + + state.activeTexture( _gl.TEXTURE0 + slot ); + state.bindTexture( _gl.TEXTURE_CUBE_MAP, properties.get( texture ).__webglTexture ); + + } + + function setTextureParameters( textureType, texture, isPowerOfTwoImage ) { + + var extension; + + if ( isPowerOfTwoImage ) { + + _gl.texParameteri( textureType, _gl.TEXTURE_WRAP_S, utils.convert( texture.wrapS ) ); + _gl.texParameteri( textureType, _gl.TEXTURE_WRAP_T, utils.convert( texture.wrapT ) ); + + _gl.texParameteri( textureType, _gl.TEXTURE_MAG_FILTER, utils.convert( texture.magFilter ) ); + _gl.texParameteri( textureType, _gl.TEXTURE_MIN_FILTER, utils.convert( texture.minFilter ) ); + + } else { + + _gl.texParameteri( textureType, _gl.TEXTURE_WRAP_S, _gl.CLAMP_TO_EDGE ); + _gl.texParameteri( textureType, _gl.TEXTURE_WRAP_T, _gl.CLAMP_TO_EDGE ); + + if ( texture.wrapS !== ClampToEdgeWrapping || texture.wrapT !== ClampToEdgeWrapping ) { + + console.warn( 'THREE.WebGLRenderer: Texture is not power of two. Texture.wrapS and Texture.wrapT should be set to THREE.ClampToEdgeWrapping.', texture ); + + } + + _gl.texParameteri( textureType, _gl.TEXTURE_MAG_FILTER, filterFallback( texture.magFilter ) ); + _gl.texParameteri( textureType, _gl.TEXTURE_MIN_FILTER, filterFallback( texture.minFilter ) ); + + if ( texture.minFilter !== NearestFilter && texture.minFilter !== LinearFilter ) { + + console.warn( 'THREE.WebGLRenderer: Texture is not power of two. Texture.minFilter should be set to THREE.NearestFilter or THREE.LinearFilter.', texture ); + + } + + } + + extension = extensions.get( 'EXT_texture_filter_anisotropic' ); + + if ( extension ) { + + if ( texture.type === FloatType && extensions.get( 'OES_texture_float_linear' ) === null ) return; + if ( texture.type === HalfFloatType && extensions.get( 'OES_texture_half_float_linear' ) === null ) return; + + if ( texture.anisotropy > 1 || properties.get( texture ).__currentAnisotropy ) { + + _gl.texParameterf( textureType, extension.TEXTURE_MAX_ANISOTROPY_EXT, Math.min( texture.anisotropy, capabilities.getMaxAnisotropy() ) ); + properties.get( texture ).__currentAnisotropy = texture.anisotropy; + + } + + } + + } + + function uploadTexture( textureProperties, texture, slot ) { + + if ( textureProperties.__webglInit === undefined ) { + + textureProperties.__webglInit = true; + + texture.addEventListener( 'dispose', onTextureDispose ); + + textureProperties.__webglTexture = _gl.createTexture(); + + info.memory.textures ++; + + } + + state.activeTexture( _gl.TEXTURE0 + slot ); + state.bindTexture( _gl.TEXTURE_2D, textureProperties.__webglTexture ); + + _gl.pixelStorei( _gl.UNPACK_FLIP_Y_WEBGL, texture.flipY ); + _gl.pixelStorei( _gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, texture.premultiplyAlpha ); + _gl.pixelStorei( _gl.UNPACK_ALIGNMENT, texture.unpackAlignment ); + + var image = clampToMaxSize( texture.image, capabilities.maxTextureSize ); + + if ( textureNeedsPowerOfTwo( texture ) && isPowerOfTwo( image ) === false ) { + + image = makePowerOfTwo( image ); + + } + + var isPowerOfTwoImage = isPowerOfTwo( image ), + glFormat = utils.convert( texture.format ), + glType = utils.convert( texture.type ); + + setTextureParameters( _gl.TEXTURE_2D, texture, isPowerOfTwoImage ); + + var mipmap, mipmaps = texture.mipmaps; + + if ( texture.isDepthTexture ) { + + // populate depth texture with dummy data + + var internalFormat = _gl.DEPTH_COMPONENT; + + if ( texture.type === FloatType ) { + + if ( ! _isWebGL2 ) throw new Error( 'Float Depth Texture only supported in WebGL2.0' ); + internalFormat = _gl.DEPTH_COMPONENT32F; + + } else if ( _isWebGL2 ) { + + // WebGL 2.0 requires signed internalformat for glTexImage2D + internalFormat = _gl.DEPTH_COMPONENT16; + + } + + if ( texture.format === DepthFormat && internalFormat === _gl.DEPTH_COMPONENT ) { + + // The error INVALID_OPERATION is generated by texImage2D if format and internalformat are + // DEPTH_COMPONENT and type is not UNSIGNED_SHORT or UNSIGNED_INT + // (https://www.khronos.org/registry/webgl/extensions/WEBGL_depth_texture/) + if ( texture.type !== UnsignedShortType && texture.type !== UnsignedIntType ) { + + console.warn( 'THREE.WebGLRenderer: Use UnsignedShortType or UnsignedIntType for DepthFormat DepthTexture.' ); + + texture.type = UnsignedShortType; + glType = utils.convert( texture.type ); + + } + + } + + // Depth stencil textures need the DEPTH_STENCIL internal format + // (https://www.khronos.org/registry/webgl/extensions/WEBGL_depth_texture/) + if ( texture.format === DepthStencilFormat ) { + + internalFormat = _gl.DEPTH_STENCIL; + + // The error INVALID_OPERATION is generated by texImage2D if format and internalformat are + // DEPTH_STENCIL and type is not UNSIGNED_INT_24_8_WEBGL. + // (https://www.khronos.org/registry/webgl/extensions/WEBGL_depth_texture/) + if ( texture.type !== UnsignedInt248Type ) { + + console.warn( 'THREE.WebGLRenderer: Use UnsignedInt248Type for DepthStencilFormat DepthTexture.' ); + + texture.type = UnsignedInt248Type; + glType = utils.convert( texture.type ); + + } + + } + + state.texImage2D( _gl.TEXTURE_2D, 0, internalFormat, image.width, image.height, 0, glFormat, glType, null ); + + } else if ( texture.isDataTexture ) { + + // use manually created mipmaps if available + // if there are no manual mipmaps + // set 0 level mipmap and then use GL to generate other mipmap levels + + if ( mipmaps.length > 0 && isPowerOfTwoImage ) { + + for ( var i = 0, il = mipmaps.length; i < il; i ++ ) { + + mipmap = mipmaps[ i ]; + state.texImage2D( _gl.TEXTURE_2D, i, glFormat, mipmap.width, mipmap.height, 0, glFormat, glType, mipmap.data ); + + } + + texture.generateMipmaps = false; + textureProperties.__maxMipLevel = mipmaps.length - 1; + + } else { + + state.texImage2D( _gl.TEXTURE_2D, 0, glFormat, image.width, image.height, 0, glFormat, glType, image.data ); + textureProperties.__maxMipLevel = 0; + + } + + } else if ( texture.isCompressedTexture ) { + + for ( var i = 0, il = mipmaps.length; i < il; i ++ ) { + + mipmap = mipmaps[ i ]; + + if ( texture.format !== RGBAFormat && texture.format !== RGBFormat ) { + + if ( state.getCompressedTextureFormats().indexOf( glFormat ) > - 1 ) { + + state.compressedTexImage2D( _gl.TEXTURE_2D, i, glFormat, mipmap.width, mipmap.height, 0, mipmap.data ); + + } else { + + console.warn( 'THREE.WebGLRenderer: Attempt to load unsupported compressed texture format in .uploadTexture()' ); + + } + + } else { + + state.texImage2D( _gl.TEXTURE_2D, i, glFormat, mipmap.width, mipmap.height, 0, glFormat, glType, mipmap.data ); + + } + + } + + textureProperties.__maxMipLevel = mipmaps.length - 1; + + } else { + + // regular Texture (image, video, canvas) + + // use manually created mipmaps if available + // if there are no manual mipmaps + // set 0 level mipmap and then use GL to generate other mipmap levels + + if ( mipmaps.length > 0 && isPowerOfTwoImage ) { + + for ( var i = 0, il = mipmaps.length; i < il; i ++ ) { + + mipmap = mipmaps[ i ]; + state.texImage2D( _gl.TEXTURE_2D, i, glFormat, glFormat, glType, mipmap ); + + } + + texture.generateMipmaps = false; + textureProperties.__maxMipLevel = mipmaps.length - 1; + + } else { + + state.texImage2D( _gl.TEXTURE_2D, 0, glFormat, glFormat, glType, image ); + textureProperties.__maxMipLevel = 0; + + } + + } + + if ( textureNeedsGenerateMipmaps( texture, isPowerOfTwoImage ) ) { + + generateMipmap( _gl.TEXTURE_2D, texture, image.width, image.height ); + + } + + textureProperties.__version = texture.version; + + if ( texture.onUpdate ) texture.onUpdate( texture ); + + } + + // Render targets + + // Setup storage for target texture and bind it to correct framebuffer + function setupFrameBufferTexture( framebuffer, renderTarget, attachment, textureTarget ) { + + var glFormat = utils.convert( renderTarget.texture.format ); + var glType = utils.convert( renderTarget.texture.type ); + state.texImage2D( textureTarget, 0, glFormat, renderTarget.width, renderTarget.height, 0, glFormat, glType, null ); + _gl.bindFramebuffer( _gl.FRAMEBUFFER, framebuffer ); + _gl.framebufferTexture2D( _gl.FRAMEBUFFER, attachment, textureTarget, properties.get( renderTarget.texture ).__webglTexture, 0 ); + _gl.bindFramebuffer( _gl.FRAMEBUFFER, null ); + + } + + // Setup storage for internal depth/stencil buffers and bind to correct framebuffer + function setupRenderBufferStorage( renderbuffer, renderTarget ) { + + _gl.bindRenderbuffer( _gl.RENDERBUFFER, renderbuffer ); + + if ( renderTarget.depthBuffer && ! renderTarget.stencilBuffer ) { + + _gl.renderbufferStorage( _gl.RENDERBUFFER, _gl.DEPTH_COMPONENT16, renderTarget.width, renderTarget.height ); + _gl.framebufferRenderbuffer( _gl.FRAMEBUFFER, _gl.DEPTH_ATTACHMENT, _gl.RENDERBUFFER, renderbuffer ); + + } else if ( renderTarget.depthBuffer && renderTarget.stencilBuffer ) { + + _gl.renderbufferStorage( _gl.RENDERBUFFER, _gl.DEPTH_STENCIL, renderTarget.width, renderTarget.height ); + _gl.framebufferRenderbuffer( _gl.FRAMEBUFFER, _gl.DEPTH_STENCIL_ATTACHMENT, _gl.RENDERBUFFER, renderbuffer ); + + } else { + + // FIXME: We don't support !depth !stencil + _gl.renderbufferStorage( _gl.RENDERBUFFER, _gl.RGBA4, renderTarget.width, renderTarget.height ); + + } + + _gl.bindRenderbuffer( _gl.RENDERBUFFER, null ); + + } + + // Setup resources for a Depth Texture for a FBO (needs an extension) + function setupDepthTexture( framebuffer, renderTarget ) { + + var isCube = ( renderTarget && renderTarget.isWebGLRenderTargetCube ); + if ( isCube ) throw new Error( 'Depth Texture with cube render targets is not supported' ); + + _gl.bindFramebuffer( _gl.FRAMEBUFFER, framebuffer ); + + if ( ! ( renderTarget.depthTexture && renderTarget.depthTexture.isDepthTexture ) ) { + + throw new Error( 'renderTarget.depthTexture must be an instance of THREE.DepthTexture' ); + + } + + // upload an empty depth texture with framebuffer size + if ( ! properties.get( renderTarget.depthTexture ).__webglTexture || + renderTarget.depthTexture.image.width !== renderTarget.width || + renderTarget.depthTexture.image.height !== renderTarget.height ) { + + renderTarget.depthTexture.image.width = renderTarget.width; + renderTarget.depthTexture.image.height = renderTarget.height; + renderTarget.depthTexture.needsUpdate = true; + + } + + setTexture2D( renderTarget.depthTexture, 0 ); + + var webglDepthTexture = properties.get( renderTarget.depthTexture ).__webglTexture; + + if ( renderTarget.depthTexture.format === DepthFormat ) { + + _gl.framebufferTexture2D( _gl.FRAMEBUFFER, _gl.DEPTH_ATTACHMENT, _gl.TEXTURE_2D, webglDepthTexture, 0 ); + + } else if ( renderTarget.depthTexture.format === DepthStencilFormat ) { + + _gl.framebufferTexture2D( _gl.FRAMEBUFFER, _gl.DEPTH_STENCIL_ATTACHMENT, _gl.TEXTURE_2D, webglDepthTexture, 0 ); + + } else { + + throw new Error( 'Unknown depthTexture format' ); + + } + + } + + // Setup GL resources for a non-texture depth buffer + function setupDepthRenderbuffer( renderTarget ) { + + var renderTargetProperties = properties.get( renderTarget ); + + var isCube = ( renderTarget.isWebGLRenderTargetCube === true ); + + if ( renderTarget.depthTexture ) { + + if ( isCube ) throw new Error( 'target.depthTexture not supported in Cube render targets' ); + + setupDepthTexture( renderTargetProperties.__webglFramebuffer, renderTarget ); + + } else { + + if ( isCube ) { + + renderTargetProperties.__webglDepthbuffer = []; + + for ( var i = 0; i < 6; i ++ ) { + + _gl.bindFramebuffer( _gl.FRAMEBUFFER, renderTargetProperties.__webglFramebuffer[ i ] ); + renderTargetProperties.__webglDepthbuffer[ i ] = _gl.createRenderbuffer(); + setupRenderBufferStorage( renderTargetProperties.__webglDepthbuffer[ i ], renderTarget ); + + } + + } else { + + _gl.bindFramebuffer( _gl.FRAMEBUFFER, renderTargetProperties.__webglFramebuffer ); + renderTargetProperties.__webglDepthbuffer = _gl.createRenderbuffer(); + setupRenderBufferStorage( renderTargetProperties.__webglDepthbuffer, renderTarget ); + + } + + } + + _gl.bindFramebuffer( _gl.FRAMEBUFFER, null ); + + } + + // Set up GL resources for the render target + function setupRenderTarget( renderTarget ) { + + var renderTargetProperties = properties.get( renderTarget ); + var textureProperties = properties.get( renderTarget.texture ); + + renderTarget.addEventListener( 'dispose', onRenderTargetDispose ); + + textureProperties.__webglTexture = _gl.createTexture(); + + info.memory.textures ++; + + var isCube = ( renderTarget.isWebGLRenderTargetCube === true ); + var isTargetPowerOfTwo = isPowerOfTwo( renderTarget ); + + // Setup framebuffer + + if ( isCube ) { + + renderTargetProperties.__webglFramebuffer = []; + + for ( var i = 0; i < 6; i ++ ) { + + renderTargetProperties.__webglFramebuffer[ i ] = _gl.createFramebuffer(); + + } + + } else { + + renderTargetProperties.__webglFramebuffer = _gl.createFramebuffer(); + + } + + // Setup color buffer + + if ( isCube ) { + + state.bindTexture( _gl.TEXTURE_CUBE_MAP, textureProperties.__webglTexture ); + setTextureParameters( _gl.TEXTURE_CUBE_MAP, renderTarget.texture, isTargetPowerOfTwo ); + + for ( var i = 0; i < 6; i ++ ) { + + setupFrameBufferTexture( renderTargetProperties.__webglFramebuffer[ i ], renderTarget, _gl.COLOR_ATTACHMENT0, _gl.TEXTURE_CUBE_MAP_POSITIVE_X + i ); + + } + + if ( textureNeedsGenerateMipmaps( renderTarget.texture, isTargetPowerOfTwo ) ) { + + generateMipmap( _gl.TEXTURE_CUBE_MAP, renderTarget.texture, renderTarget.width, renderTarget.height ); + + } + + state.bindTexture( _gl.TEXTURE_CUBE_MAP, null ); + + } else { + + state.bindTexture( _gl.TEXTURE_2D, textureProperties.__webglTexture ); + setTextureParameters( _gl.TEXTURE_2D, renderTarget.texture, isTargetPowerOfTwo ); + setupFrameBufferTexture( renderTargetProperties.__webglFramebuffer, renderTarget, _gl.COLOR_ATTACHMENT0, _gl.TEXTURE_2D ); + + if ( textureNeedsGenerateMipmaps( renderTarget.texture, isTargetPowerOfTwo ) ) { + + generateMipmap( _gl.TEXTURE_2D, renderTarget.texture, renderTarget.width, renderTarget.height ); + + } + + state.bindTexture( _gl.TEXTURE_2D, null ); + + } + + // Setup depth and stencil buffers + + if ( renderTarget.depthBuffer ) { + + setupDepthRenderbuffer( renderTarget ); + + } + + } + + function updateRenderTargetMipmap( renderTarget ) { + + var texture = renderTarget.texture; + var isTargetPowerOfTwo = isPowerOfTwo( renderTarget ); + + if ( textureNeedsGenerateMipmaps( texture, isTargetPowerOfTwo ) ) { + + var target = renderTarget.isWebGLRenderTargetCube ? _gl.TEXTURE_CUBE_MAP : _gl.TEXTURE_2D; + var webglTexture = properties.get( texture ).__webglTexture; + + state.bindTexture( target, webglTexture ); + generateMipmap( target, texture, renderTarget.width, renderTarget.height ); + state.bindTexture( target, null ); + + } + + } + + function updateVideoTexture( texture ) { + + var id = texture.id; + var frame = info.render.frame; + + // Check the last frame we updated the VideoTexture + + if ( _videoTextures[ id ] !== frame ) { + + _videoTextures[ id ] = frame; + texture.update(); + + } + + } + + this.setTexture2D = setTexture2D; + this.setTextureCube = setTextureCube; + this.setTextureCubeDynamic = setTextureCubeDynamic; + this.setupRenderTarget = setupRenderTarget; + this.updateRenderTargetMipmap = updateRenderTargetMipmap; + + } + + /** + * @author thespite / http://www.twitter.com/thespite + */ + + function WebGLUtils( gl, extensions ) { + + function convert( p ) { + + var extension; + + if ( p === RepeatWrapping ) return gl.REPEAT; + if ( p === ClampToEdgeWrapping ) return gl.CLAMP_TO_EDGE; + if ( p === MirroredRepeatWrapping ) return gl.MIRRORED_REPEAT; + + if ( p === NearestFilter ) return gl.NEAREST; + if ( p === NearestMipMapNearestFilter ) return gl.NEAREST_MIPMAP_NEAREST; + if ( p === NearestMipMapLinearFilter ) return gl.NEAREST_MIPMAP_LINEAR; + + if ( p === LinearFilter ) return gl.LINEAR; + if ( p === LinearMipMapNearestFilter ) return gl.LINEAR_MIPMAP_NEAREST; + if ( p === LinearMipMapLinearFilter ) return gl.LINEAR_MIPMAP_LINEAR; + + if ( p === UnsignedByteType ) return gl.UNSIGNED_BYTE; + if ( p === UnsignedShort4444Type ) return gl.UNSIGNED_SHORT_4_4_4_4; + if ( p === UnsignedShort5551Type ) return gl.UNSIGNED_SHORT_5_5_5_1; + if ( p === UnsignedShort565Type ) return gl.UNSIGNED_SHORT_5_6_5; + + if ( p === ByteType ) return gl.BYTE; + if ( p === ShortType ) return gl.SHORT; + if ( p === UnsignedShortType ) return gl.UNSIGNED_SHORT; + if ( p === IntType ) return gl.INT; + if ( p === UnsignedIntType ) return gl.UNSIGNED_INT; + if ( p === FloatType ) return gl.FLOAT; + + if ( p === HalfFloatType ) { + + extension = extensions.get( 'OES_texture_half_float' ); + + if ( extension !== null ) return extension.HALF_FLOAT_OES; + + } + + if ( p === AlphaFormat ) return gl.ALPHA; + if ( p === RGBFormat ) return gl.RGB; + if ( p === RGBAFormat ) return gl.RGBA; + if ( p === LuminanceFormat ) return gl.LUMINANCE; + if ( p === LuminanceAlphaFormat ) return gl.LUMINANCE_ALPHA; + if ( p === DepthFormat ) return gl.DEPTH_COMPONENT; + if ( p === DepthStencilFormat ) return gl.DEPTH_STENCIL; + + if ( p === AddEquation ) return gl.FUNC_ADD; + if ( p === SubtractEquation ) return gl.FUNC_SUBTRACT; + if ( p === ReverseSubtractEquation ) return gl.FUNC_REVERSE_SUBTRACT; + + if ( p === ZeroFactor ) return gl.ZERO; + if ( p === OneFactor ) return gl.ONE; + if ( p === SrcColorFactor ) return gl.SRC_COLOR; + if ( p === OneMinusSrcColorFactor ) return gl.ONE_MINUS_SRC_COLOR; + if ( p === SrcAlphaFactor ) return gl.SRC_ALPHA; + if ( p === OneMinusSrcAlphaFactor ) return gl.ONE_MINUS_SRC_ALPHA; + if ( p === DstAlphaFactor ) return gl.DST_ALPHA; + if ( p === OneMinusDstAlphaFactor ) return gl.ONE_MINUS_DST_ALPHA; + + if ( p === DstColorFactor ) return gl.DST_COLOR; + if ( p === OneMinusDstColorFactor ) return gl.ONE_MINUS_DST_COLOR; + if ( p === SrcAlphaSaturateFactor ) return gl.SRC_ALPHA_SATURATE; + + if ( p === RGB_S3TC_DXT1_Format || p === RGBA_S3TC_DXT1_Format || + p === RGBA_S3TC_DXT3_Format || p === RGBA_S3TC_DXT5_Format ) { + + extension = extensions.get( 'WEBGL_compressed_texture_s3tc' ); + + if ( extension !== null ) { + + if ( p === RGB_S3TC_DXT1_Format ) return extension.COMPRESSED_RGB_S3TC_DXT1_EXT; + if ( p === RGBA_S3TC_DXT1_Format ) return extension.COMPRESSED_RGBA_S3TC_DXT1_EXT; + if ( p === RGBA_S3TC_DXT3_Format ) return extension.COMPRESSED_RGBA_S3TC_DXT3_EXT; + if ( p === RGBA_S3TC_DXT5_Format ) return extension.COMPRESSED_RGBA_S3TC_DXT5_EXT; + + } + + } + + if ( p === RGB_PVRTC_4BPPV1_Format || p === RGB_PVRTC_2BPPV1_Format || + p === RGBA_PVRTC_4BPPV1_Format || p === RGBA_PVRTC_2BPPV1_Format ) { + + extension = extensions.get( 'WEBGL_compressed_texture_pvrtc' ); + + if ( extension !== null ) { + + if ( p === RGB_PVRTC_4BPPV1_Format ) return extension.COMPRESSED_RGB_PVRTC_4BPPV1_IMG; + if ( p === RGB_PVRTC_2BPPV1_Format ) return extension.COMPRESSED_RGB_PVRTC_2BPPV1_IMG; + if ( p === RGBA_PVRTC_4BPPV1_Format ) return extension.COMPRESSED_RGBA_PVRTC_4BPPV1_IMG; + if ( p === RGBA_PVRTC_2BPPV1_Format ) return extension.COMPRESSED_RGBA_PVRTC_2BPPV1_IMG; + + } + + } + + if ( p === RGB_ETC1_Format ) { + + extension = extensions.get( 'WEBGL_compressed_texture_etc1' ); + + if ( extension !== null ) return extension.COMPRESSED_RGB_ETC1_WEBGL; + + } + + if ( p === RGBA_ASTC_4x4_Format || p === RGBA_ASTC_5x4_Format || p === RGBA_ASTC_5x5_Format || + p === RGBA_ASTC_6x5_Format || p === RGBA_ASTC_6x6_Format || p === RGBA_ASTC_8x5_Format || + p === RGBA_ASTC_8x6_Format || p === RGBA_ASTC_8x8_Format || p === RGBA_ASTC_10x5_Format || + p === RGBA_ASTC_10x6_Format || p === RGBA_ASTC_10x8_Format || p === RGBA_ASTC_10x10_Format || + p === RGBA_ASTC_12x10_Format || p === RGBA_ASTC_12x12_Format ) { + + extension = extensions.get( 'WEBGL_compressed_texture_astc' ); + + if ( extension !== null ) { + + return p; + + } + + } + + if ( p === MinEquation || p === MaxEquation ) { + + extension = extensions.get( 'EXT_blend_minmax' ); + + if ( extension !== null ) { + + if ( p === MinEquation ) return extension.MIN_EXT; + if ( p === MaxEquation ) return extension.MAX_EXT; + + } + + } + + if ( p === UnsignedInt248Type ) { + + extension = extensions.get( 'WEBGL_depth_texture' ); + + if ( extension !== null ) return extension.UNSIGNED_INT_24_8_WEBGL; + + } + + return 0; + + } + + return { convert: convert }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + * @author greggman / http://games.greggman.com/ + * @author zz85 / http://www.lab4games.net/zz85/blog + * @author tschw + */ + + function PerspectiveCamera( fov, aspect, near, far ) { + + Camera.call( this ); + + this.type = 'PerspectiveCamera'; + + this.fov = fov !== undefined ? fov : 50; + this.zoom = 1; + + this.near = near !== undefined ? near : 0.1; + this.far = far !== undefined ? far : 2000; + this.focus = 10; + + this.aspect = aspect !== undefined ? aspect : 1; + this.view = null; + + this.filmGauge = 35; // width of the film (default in millimeters) + this.filmOffset = 0; // horizontal film offset (same unit as gauge) + + this.updateProjectionMatrix(); + + } + + PerspectiveCamera.prototype = Object.assign( Object.create( Camera.prototype ), { + + constructor: PerspectiveCamera, + + isPerspectiveCamera: true, + + copy: function ( source, recursive ) { + + Camera.prototype.copy.call( this, source, recursive ); + + this.fov = source.fov; + this.zoom = source.zoom; + + this.near = source.near; + this.far = source.far; + this.focus = source.focus; + + this.aspect = source.aspect; + this.view = source.view === null ? null : Object.assign( {}, source.view ); + + this.filmGauge = source.filmGauge; + this.filmOffset = source.filmOffset; + + return this; + + }, + + /** + * Sets the FOV by focal length in respect to the current .filmGauge. + * + * The default film gauge is 35, so that the focal length can be specified for + * a 35mm (full frame) camera. + * + * Values for focal length and film gauge must have the same unit. + */ + setFocalLength: function ( focalLength ) { + + // see http://www.bobatkins.com/photography/technical/field_of_view.html + var vExtentSlope = 0.5 * this.getFilmHeight() / focalLength; + + this.fov = _Math.RAD2DEG * 2 * Math.atan( vExtentSlope ); + this.updateProjectionMatrix(); + + }, + + /** + * Calculates the focal length from the current .fov and .filmGauge. + */ + getFocalLength: function () { + + var vExtentSlope = Math.tan( _Math.DEG2RAD * 0.5 * this.fov ); + + return 0.5 * this.getFilmHeight() / vExtentSlope; + + }, + + getEffectiveFOV: function () { + + return _Math.RAD2DEG * 2 * Math.atan( + Math.tan( _Math.DEG2RAD * 0.5 * this.fov ) / this.zoom ); + + }, + + getFilmWidth: function () { + + // film not completely covered in portrait format (aspect < 1) + return this.filmGauge * Math.min( this.aspect, 1 ); + + }, + + getFilmHeight: function () { + + // film not completely covered in landscape format (aspect > 1) + return this.filmGauge / Math.max( this.aspect, 1 ); + + }, + + /** + * Sets an offset in a larger frustum. This is useful for multi-window or + * multi-monitor/multi-machine setups. + * + * For example, if you have 3x2 monitors and each monitor is 1920x1080 and + * the monitors are in grid like this + * + * +---+---+---+ + * | A | B | C | + * +---+---+---+ + * | D | E | F | + * +---+---+---+ + * + * then for each monitor you would call it like this + * + * var w = 1920; + * var h = 1080; + * var fullWidth = w * 3; + * var fullHeight = h * 2; + * + * --A-- + * camera.setOffset( fullWidth, fullHeight, w * 0, h * 0, w, h ); + * --B-- + * camera.setOffset( fullWidth, fullHeight, w * 1, h * 0, w, h ); + * --C-- + * camera.setOffset( fullWidth, fullHeight, w * 2, h * 0, w, h ); + * --D-- + * camera.setOffset( fullWidth, fullHeight, w * 0, h * 1, w, h ); + * --E-- + * camera.setOffset( fullWidth, fullHeight, w * 1, h * 1, w, h ); + * --F-- + * camera.setOffset( fullWidth, fullHeight, w * 2, h * 1, w, h ); + * + * Note there is no reason monitors have to be the same size or in a grid. + */ + setViewOffset: function ( fullWidth, fullHeight, x, y, width, height ) { + + this.aspect = fullWidth / fullHeight; + + if ( this.view === null ) { + + this.view = { + enabled: true, + fullWidth: 1, + fullHeight: 1, + offsetX: 0, + offsetY: 0, + width: 1, + height: 1 + }; + + } + + this.view.enabled = true; + this.view.fullWidth = fullWidth; + this.view.fullHeight = fullHeight; + this.view.offsetX = x; + this.view.offsetY = y; + this.view.width = width; + this.view.height = height; + + this.updateProjectionMatrix(); + + }, + + clearViewOffset: function () { + + if ( this.view !== null ) { + + this.view.enabled = false; + + } + + this.updateProjectionMatrix(); + + }, + + updateProjectionMatrix: function () { + + var near = this.near, + top = near * Math.tan( + _Math.DEG2RAD * 0.5 * this.fov ) / this.zoom, + height = 2 * top, + width = this.aspect * height, + left = - 0.5 * width, + view = this.view; + + if ( this.view !== null && this.view.enabled ) { + + var fullWidth = view.fullWidth, + fullHeight = view.fullHeight; + + left += view.offsetX * width / fullWidth; + top -= view.offsetY * height / fullHeight; + width *= view.width / fullWidth; + height *= view.height / fullHeight; + + } + + var skew = this.filmOffset; + if ( skew !== 0 ) left += near * skew / this.getFilmWidth(); + + this.projectionMatrix.makePerspective( left, left + width, top, top - height, near, this.far ); + + }, + + toJSON: function ( meta ) { + + var data = Object3D.prototype.toJSON.call( this, meta ); + + data.object.fov = this.fov; + data.object.zoom = this.zoom; + + data.object.near = this.near; + data.object.far = this.far; + data.object.focus = this.focus; + + data.object.aspect = this.aspect; + + if ( this.view !== null ) data.object.view = Object.assign( {}, this.view ); + + data.object.filmGauge = this.filmGauge; + data.object.filmOffset = this.filmOffset; + + return data; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function ArrayCamera( array ) { + + PerspectiveCamera.call( this ); + + this.cameras = array || []; + + } + + ArrayCamera.prototype = Object.assign( Object.create( PerspectiveCamera.prototype ), { + + constructor: ArrayCamera, + + isArrayCamera: true + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebVRManager( renderer ) { + + var scope = this; + + var device = null; + var frameData = null; + + var poseTarget = null; + + var standingMatrix = new Matrix4(); + var standingMatrixInverse = new Matrix4(); + + if ( typeof window !== 'undefined' && 'VRFrameData' in window ) { + + frameData = new window.VRFrameData(); + window.addEventListener( 'vrdisplaypresentchange', onVRDisplayPresentChange, false ); + + } + + var matrixWorldInverse = new Matrix4(); + var tempQuaternion = new Quaternion(); + var tempPosition = new Vector3(); + + var cameraL = new PerspectiveCamera(); + cameraL.bounds = new Vector4( 0.0, 0.0, 0.5, 1.0 ); + cameraL.layers.enable( 1 ); + + var cameraR = new PerspectiveCamera(); + cameraR.bounds = new Vector4( 0.5, 0.0, 0.5, 1.0 ); + cameraR.layers.enable( 2 ); + + var cameraVR = new ArrayCamera( [ cameraL, cameraR ] ); + cameraVR.layers.enable( 1 ); + cameraVR.layers.enable( 2 ); + + // + + function isPresenting() { + + return device !== null && device.isPresenting === true; + + } + + var currentSize, currentPixelRatio; + + function onVRDisplayPresentChange() { + + if ( isPresenting() ) { + + var eyeParameters = device.getEyeParameters( 'left' ); + var renderWidth = eyeParameters.renderWidth; + var renderHeight = eyeParameters.renderHeight; + + currentPixelRatio = renderer.getPixelRatio(); + currentSize = renderer.getSize(); + + renderer.setDrawingBufferSize( renderWidth * 2, renderHeight, 1 ); + + animation.start(); + + } else if ( scope.enabled ) { + + renderer.setDrawingBufferSize( currentSize.width, currentSize.height, currentPixelRatio ); + + animation.stop(); + + } + + } + + // + + this.enabled = false; + this.userHeight = 1.6; + + this.getDevice = function () { + + return device; + + }; + + this.setDevice = function ( value ) { + + if ( value !== undefined ) device = value; + + animation.setContext( value ); + + }; + + this.setPoseTarget = function ( object ) { + + if ( object !== undefined ) poseTarget = object; + + }; + + this.getCamera = function ( camera ) { + + if ( device === null ) return camera; + + device.depthNear = camera.near; + device.depthFar = camera.far; + + device.getFrameData( frameData ); + + // + + var stageParameters = device.stageParameters; + + if ( stageParameters ) { + + standingMatrix.fromArray( stageParameters.sittingToStandingTransform ); + + } else { + + standingMatrix.makeTranslation( 0, scope.userHeight, 0 ); + + } + + + var pose = frameData.pose; + var poseObject = poseTarget !== null ? poseTarget : camera; + + // We want to manipulate poseObject by its position and quaternion components since users may rely on them. + poseObject.matrix.copy( standingMatrix ); + poseObject.matrix.decompose( poseObject.position, poseObject.quaternion, poseObject.scale ); + + if ( pose.orientation !== null ) { + + tempQuaternion.fromArray( pose.orientation ); + poseObject.quaternion.multiply( tempQuaternion ); + + } + + if ( pose.position !== null ) { + + tempQuaternion.setFromRotationMatrix( standingMatrix ); + tempPosition.fromArray( pose.position ); + tempPosition.applyQuaternion( tempQuaternion ); + poseObject.position.add( tempPosition ); + + } + + poseObject.updateMatrixWorld(); + + if ( device.isPresenting === false ) return camera; + + // + + cameraL.near = camera.near; + cameraR.near = camera.near; + + cameraL.far = camera.far; + cameraR.far = camera.far; + + cameraVR.matrixWorld.copy( camera.matrixWorld ); + cameraVR.matrixWorldInverse.copy( camera.matrixWorldInverse ); + + cameraL.matrixWorldInverse.fromArray( frameData.leftViewMatrix ); + cameraR.matrixWorldInverse.fromArray( frameData.rightViewMatrix ); + + // TODO (mrdoob) Double check this code + + standingMatrixInverse.getInverse( standingMatrix ); + + cameraL.matrixWorldInverse.multiply( standingMatrixInverse ); + cameraR.matrixWorldInverse.multiply( standingMatrixInverse ); + + var parent = poseObject.parent; + + if ( parent !== null ) { + + matrixWorldInverse.getInverse( parent.matrixWorld ); + + cameraL.matrixWorldInverse.multiply( matrixWorldInverse ); + cameraR.matrixWorldInverse.multiply( matrixWorldInverse ); + + } + + // envMap and Mirror needs camera.matrixWorld + + cameraL.matrixWorld.getInverse( cameraL.matrixWorldInverse ); + cameraR.matrixWorld.getInverse( cameraR.matrixWorldInverse ); + + cameraL.projectionMatrix.fromArray( frameData.leftProjectionMatrix ); + cameraR.projectionMatrix.fromArray( frameData.rightProjectionMatrix ); + + // HACK (mrdoob) + // https://github.com/w3c/webvr/issues/203 + + cameraVR.projectionMatrix.copy( cameraL.projectionMatrix ); + + // + + var layers = device.getLayers(); + + if ( layers.length ) { + + var layer = layers[ 0 ]; + + if ( layer.leftBounds !== null && layer.leftBounds.length === 4 ) { + + cameraL.bounds.fromArray( layer.leftBounds ); + + } + + if ( layer.rightBounds !== null && layer.rightBounds.length === 4 ) { + + cameraR.bounds.fromArray( layer.rightBounds ); + + } + + } + + return cameraVR; + + }; + + this.getStandingMatrix = function () { + + return standingMatrix; + + }; + + this.isPresenting = isPresenting; + + // Animation Loop + + var animation = new WebGLAnimation(); + + this.setAnimationLoop = function ( callback ) { + + animation.setAnimationLoop( callback ); + + }; + + this.submitFrame = function () { + + if ( isPresenting() ) device.submitFrame(); + + }; + + this.dispose = function () { + + if ( typeof window !== 'undefined' ) { + + window.removeEventListener( 'vrdisplaypresentchange', onVRDisplayPresentChange ); + + } + + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function WebXRManager( renderer ) { + + var gl = renderer.context; + + var device = null; + var session = null; + + var frameOfRef = null; + + var pose = null; + + function isPresenting() { + + return session !== null && frameOfRef !== null; + + } + + // + + var cameraL = new PerspectiveCamera(); + cameraL.layers.enable( 1 ); + cameraL.viewport = new Vector4(); + + var cameraR = new PerspectiveCamera(); + cameraR.layers.enable( 2 ); + cameraR.viewport = new Vector4(); + + var cameraVR = new ArrayCamera( [ cameraL, cameraR ] ); + cameraVR.layers.enable( 1 ); + cameraVR.layers.enable( 2 ); + + // + + this.enabled = false; + + this.getDevice = function () { + + return device; + + }; + + this.setDevice = function ( value ) { + + if ( value !== undefined ) device = value; + + gl.setCompatibleXRDevice( value ); + + }; + + // + + this.setSession = function ( value, options ) { + + session = value; + + if ( session !== null ) { + + session.addEventListener( 'end', function () { + + renderer.setFramebuffer( null ); + animation.stop(); + + } ); + + session.baseLayer = new XRWebGLLayer( session, gl ); + session.requestFrameOfReference( options.frameOfReferenceType ).then( function ( value ) { + + frameOfRef = value; + + renderer.setFramebuffer( session.baseLayer.framebuffer ); + + animation.setContext( session ); + animation.start(); + + } ); + + } + + }; + + function updateCamera( camera, parent ) { + + if ( parent === null ) { + + camera.matrixWorld.copy( camera.matrix ); + + } else { + + camera.matrixWorld.multiplyMatrices( parent.matrixWorld, camera.matrix ); + + } + + camera.matrixWorldInverse.getInverse( camera.matrixWorld ); + + } + + this.getCamera = function ( camera ) { + + if ( isPresenting() ) { + + var parent = camera.parent; + var cameras = cameraVR.cameras; + + // apply camera.parent to cameraVR + + updateCamera( cameraVR, parent ); + + for ( var i = 0; i < cameras.length; i ++ ) { + + updateCamera( cameras[ i ], parent ); + + } + + // update camera and its children + + camera.matrixWorld.copy( cameraVR.matrixWorld ); + + var children = camera.children; + + for ( var i = 0, l = children.length; i < l; i ++ ) { + + children[ i ].updateMatrixWorld( true ); + + } + + return cameraVR; + + } + + return camera; + + }; + + this.isPresenting = isPresenting; + + // Animation Loop + + var onAnimationFrameCallback = null; + + function onAnimationFrame( time, frame ) { + + pose = frame.getDevicePose( frameOfRef ); + + var layer = session.baseLayer; + var views = frame.views; + + for ( var i = 0; i < views.length; i ++ ) { + + var view = views[ i ]; + var viewport = layer.getViewport( view ); + var viewMatrix = pose.getViewMatrix( view ); + + var camera = cameraVR.cameras[ i ]; + camera.matrix.fromArray( viewMatrix ).getInverse( camera.matrix ); + camera.projectionMatrix.fromArray( view.projectionMatrix ); + camera.viewport.set( viewport.x, viewport.y, viewport.width, viewport.height ); + + if ( i === 0 ) { + + cameraVR.matrix.copy( camera.matrix ); + + // HACK (mrdoob) + // https://github.com/w3c/webvr/issues/203 + + cameraVR.projectionMatrix.copy( camera.projectionMatrix ); + + } + + } + + if ( onAnimationFrameCallback ) onAnimationFrameCallback(); + + } + + var animation = new WebGLAnimation(); + animation.setAnimationLoop( onAnimationFrame ); + + this.setAnimationLoop = function ( callback ) { + + onAnimationFrameCallback = callback; + + }; + + // DEPRECATED + + this.getStandingMatrix = function () { + + console.warn( 'THREE.WebXRManager: getStandingMatrix() is no longer needed.' ); + return new THREE.Matrix4(); + + }; + + this.submitFrame = function () {}; + + } + + /** + * @author supereggbert / http://www.paulbrunt.co.uk/ + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + * @author szimek / https://github.com/szimek/ + * @author tschw + */ + + function WebGLRenderer( parameters ) { + + console.log( 'THREE.WebGLRenderer', REVISION ); + + parameters = parameters || {}; + + var _canvas = parameters.canvas !== undefined ? parameters.canvas : document.createElementNS( 'http://www.w3.org/1999/xhtml', 'canvas' ), + _context = parameters.context !== undefined ? parameters.context : null, + + _alpha = parameters.alpha !== undefined ? parameters.alpha : false, + _depth = parameters.depth !== undefined ? parameters.depth : true, + _stencil = parameters.stencil !== undefined ? parameters.stencil : true, + _antialias = parameters.antialias !== undefined ? parameters.antialias : false, + _premultipliedAlpha = parameters.premultipliedAlpha !== undefined ? parameters.premultipliedAlpha : true, + _preserveDrawingBuffer = parameters.preserveDrawingBuffer !== undefined ? parameters.preserveDrawingBuffer : false, + _powerPreference = parameters.powerPreference !== undefined ? parameters.powerPreference : 'default'; + + var currentRenderList = null; + var currentRenderState = null; + + // public properties + + this.domElement = _canvas; + this.context = null; + + // clearing + + this.autoClear = true; + this.autoClearColor = true; + this.autoClearDepth = true; + this.autoClearStencil = true; + + // scene graph + + this.sortObjects = true; + + // user-defined clipping + + this.clippingPlanes = []; + this.localClippingEnabled = false; + + // physically based shading + + this.gammaFactor = 2.0; // for backwards compatibility + this.gammaInput = false; + this.gammaOutput = false; + + // physical lights + + this.physicallyCorrectLights = false; + + // tone mapping + + this.toneMapping = LinearToneMapping; + this.toneMappingExposure = 1.0; + this.toneMappingWhitePoint = 1.0; + + // morphs + + this.maxMorphTargets = 8; + this.maxMorphNormals = 4; + + // internal properties + + var _this = this, + + _isContextLost = false, + + // internal state cache + + _framebuffer = null, + + _currentRenderTarget = null, + _currentFramebuffer = null, + _currentMaterialId = - 1, + _currentGeometryProgram = '', + + _currentCamera = null, + _currentArrayCamera = null, + + _currentViewport = new Vector4(), + _currentScissor = new Vector4(), + _currentScissorTest = null, + + // + + _usedTextureUnits = 0, + + // + + _width = _canvas.width, + _height = _canvas.height, + + _pixelRatio = 1, + + _viewport = new Vector4( 0, 0, _width, _height ), + _scissor = new Vector4( 0, 0, _width, _height ), + _scissorTest = false, + + // frustum + + _frustum = new Frustum(), + + // clipping + + _clipping = new WebGLClipping(), + _clippingEnabled = false, + _localClippingEnabled = false, + + // camera matrices cache + + _projScreenMatrix = new Matrix4(), + + _vector3 = new Vector3(); + + function getTargetPixelRatio() { + + return _currentRenderTarget === null ? _pixelRatio : 1; + + } + + // initialize + + var _gl; + + try { + + var contextAttributes = { + alpha: _alpha, + depth: _depth, + stencil: _stencil, + antialias: _antialias, + premultipliedAlpha: _premultipliedAlpha, + preserveDrawingBuffer: _preserveDrawingBuffer, + powerPreference: _powerPreference + }; + + // event listeners must be registered before WebGL context is created, see #12753 + + _canvas.addEventListener( 'webglcontextlost', onContextLost, false ); + _canvas.addEventListener( 'webglcontextrestored', onContextRestore, false ); + + _gl = _context || _canvas.getContext( 'webgl', contextAttributes ) || _canvas.getContext( 'experimental-webgl', contextAttributes ); + + if ( _gl === null ) { + + if ( _canvas.getContext( 'webgl' ) !== null ) { + + throw new Error( 'Error creating WebGL context with your selected attributes.' ); + + } else { + + throw new Error( 'Error creating WebGL context.' ); + + } + + } + + // Some experimental-webgl implementations do not have getShaderPrecisionFormat + + if ( _gl.getShaderPrecisionFormat === undefined ) { + + _gl.getShaderPrecisionFormat = function () { + + return { 'rangeMin': 1, 'rangeMax': 1, 'precision': 1 }; + + }; + + } + + } catch ( error ) { + + console.error( 'THREE.WebGLRenderer: ' + error.message ); + + } + + var extensions, capabilities, state, info; + var properties, textures, attributes, geometries, objects; + var programCache, renderLists, renderStates; + + var background, morphtargets, bufferRenderer, indexedBufferRenderer; + var spriteRenderer; + + var utils; + + function initGLContext() { + + extensions = new WebGLExtensions( _gl ); + extensions.get( 'WEBGL_depth_texture' ); + extensions.get( 'OES_texture_float' ); + extensions.get( 'OES_texture_float_linear' ); + extensions.get( 'OES_texture_half_float' ); + extensions.get( 'OES_texture_half_float_linear' ); + extensions.get( 'OES_standard_derivatives' ); + extensions.get( 'OES_element_index_uint' ); + extensions.get( 'ANGLE_instanced_arrays' ); + + utils = new WebGLUtils( _gl, extensions ); + + capabilities = new WebGLCapabilities( _gl, extensions, parameters ); + + state = new WebGLState( _gl, extensions, utils ); + state.scissor( _currentScissor.copy( _scissor ).multiplyScalar( _pixelRatio ) ); + state.viewport( _currentViewport.copy( _viewport ).multiplyScalar( _pixelRatio ) ); + + info = new WebGLInfo( _gl ); + properties = new WebGLProperties(); + textures = new WebGLTextures( _gl, extensions, state, properties, capabilities, utils, info ); + attributes = new WebGLAttributes( _gl ); + geometries = new WebGLGeometries( _gl, attributes, info ); + objects = new WebGLObjects( geometries, info ); + morphtargets = new WebGLMorphtargets( _gl ); + programCache = new WebGLPrograms( _this, extensions, capabilities ); + renderLists = new WebGLRenderLists(); + renderStates = new WebGLRenderStates(); + + background = new WebGLBackground( _this, state, objects, _premultipliedAlpha ); + + bufferRenderer = new WebGLBufferRenderer( _gl, extensions, info ); + indexedBufferRenderer = new WebGLIndexedBufferRenderer( _gl, extensions, info ); + + spriteRenderer = new WebGLSpriteRenderer( _this, _gl, state, textures, capabilities ); + + info.programs = programCache.programs; + + _this.context = _gl; + _this.capabilities = capabilities; + _this.extensions = extensions; + _this.properties = properties; + _this.renderLists = renderLists; + _this.state = state; + _this.info = info; + + } + + initGLContext(); + + // vr + + var vr = ( 'xr' in navigator ) ? new WebXRManager( _this ) : new WebVRManager( _this ); + + this.vr = vr; + + // shadow map + + var shadowMap = new WebGLShadowMap( _this, objects, capabilities.maxTextureSize ); + + this.shadowMap = shadowMap; + + // API + + this.getContext = function () { + + return _gl; + + }; + + this.getContextAttributes = function () { + + return _gl.getContextAttributes(); + + }; + + this.forceContextLoss = function () { + + var extension = extensions.get( 'WEBGL_lose_context' ); + if ( extension ) extension.loseContext(); + + }; + + this.forceContextRestore = function () { + + var extension = extensions.get( 'WEBGL_lose_context' ); + if ( extension ) extension.restoreContext(); + + }; + + this.getPixelRatio = function () { + + return _pixelRatio; + + }; + + this.setPixelRatio = function ( value ) { + + if ( value === undefined ) return; + + _pixelRatio = value; + + this.setSize( _width, _height, false ); + + }; + + this.getSize = function () { + + return { + width: _width, + height: _height + }; + + }; + + this.setSize = function ( width, height, updateStyle ) { + + if ( vr.isPresenting() ) { + + console.warn( 'THREE.WebGLRenderer: Can\'t change size while VR device is presenting.' ); + return; + + } + + _width = width; + _height = height; + + _canvas.width = width * _pixelRatio; + _canvas.height = height * _pixelRatio; + + if ( updateStyle !== false ) { + + _canvas.style.width = width + 'px'; + _canvas.style.height = height + 'px'; + + } + + this.setViewport( 0, 0, width, height ); + + }; + + this.getDrawingBufferSize = function () { + + return { + width: _width * _pixelRatio, + height: _height * _pixelRatio + }; + + }; + + this.setDrawingBufferSize = function ( width, height, pixelRatio ) { + + _width = width; + _height = height; + + _pixelRatio = pixelRatio; + + _canvas.width = width * pixelRatio; + _canvas.height = height * pixelRatio; + + this.setViewport( 0, 0, width, height ); + + }; + + this.getCurrentViewport = function () { + + return _currentViewport; + + }; + + this.setViewport = function ( x, y, width, height ) { + + _viewport.set( x, _height - y - height, width, height ); + state.viewport( _currentViewport.copy( _viewport ).multiplyScalar( _pixelRatio ) ); + + }; + + this.setScissor = function ( x, y, width, height ) { + + _scissor.set( x, _height - y - height, width, height ); + state.scissor( _currentScissor.copy( _scissor ).multiplyScalar( _pixelRatio ) ); + + }; + + this.setScissorTest = function ( boolean ) { + + state.setScissorTest( _scissorTest = boolean ); + + }; + + // Clearing + + this.getClearColor = function () { + + return background.getClearColor(); + + }; + + this.setClearColor = function () { + + background.setClearColor.apply( background, arguments ); + + }; + + this.getClearAlpha = function () { + + return background.getClearAlpha(); + + }; + + this.setClearAlpha = function () { + + background.setClearAlpha.apply( background, arguments ); + + }; + + this.clear = function ( color, depth, stencil ) { + + var bits = 0; + + if ( color === undefined || color ) bits |= _gl.COLOR_BUFFER_BIT; + if ( depth === undefined || depth ) bits |= _gl.DEPTH_BUFFER_BIT; + if ( stencil === undefined || stencil ) bits |= _gl.STENCIL_BUFFER_BIT; + + _gl.clear( bits ); + + }; + + this.clearColor = function () { + + this.clear( true, false, false ); + + }; + + this.clearDepth = function () { + + this.clear( false, true, false ); + + }; + + this.clearStencil = function () { + + this.clear( false, false, true ); + + }; + + this.clearTarget = function ( renderTarget, color, depth, stencil ) { + + this.setRenderTarget( renderTarget ); + this.clear( color, depth, stencil ); + + }; + + // + + this.dispose = function () { + + _canvas.removeEventListener( 'webglcontextlost', onContextLost, false ); + _canvas.removeEventListener( 'webglcontextrestored', onContextRestore, false ); + + renderLists.dispose(); + renderStates.dispose(); + properties.dispose(); + objects.dispose(); + + vr.dispose(); + + animation.stop(); + + }; + + // Events + + function onContextLost( event ) { + + event.preventDefault(); + + console.log( 'THREE.WebGLRenderer: Context Lost.' ); + + _isContextLost = true; + + } + + function onContextRestore( /* event */ ) { + + console.log( 'THREE.WebGLRenderer: Context Restored.' ); + + _isContextLost = false; + + initGLContext(); + + } + + function onMaterialDispose( event ) { + + var material = event.target; + + material.removeEventListener( 'dispose', onMaterialDispose ); + + deallocateMaterial( material ); + + } + + // Buffer deallocation + + function deallocateMaterial( material ) { + + releaseMaterialProgramReference( material ); + + properties.remove( material ); + + } + + + function releaseMaterialProgramReference( material ) { + + var programInfo = properties.get( material ).program; + + material.program = undefined; + + if ( programInfo !== undefined ) { + + programCache.releaseProgram( programInfo ); + + } + + } + + // Buffer rendering + + function renderObjectImmediate( object, program, material ) { + + object.render( function ( object ) { + + _this.renderBufferImmediate( object, program, material ); + + } ); + + } + + this.renderBufferImmediate = function ( object, program, material ) { + + state.initAttributes(); + + var buffers = properties.get( object ); + + if ( object.hasPositions && ! buffers.position ) buffers.position = _gl.createBuffer(); + if ( object.hasNormals && ! buffers.normal ) buffers.normal = _gl.createBuffer(); + if ( object.hasUvs && ! buffers.uv ) buffers.uv = _gl.createBuffer(); + if ( object.hasColors && ! buffers.color ) buffers.color = _gl.createBuffer(); + + var programAttributes = program.getAttributes(); + + if ( object.hasPositions ) { + + _gl.bindBuffer( _gl.ARRAY_BUFFER, buffers.position ); + _gl.bufferData( _gl.ARRAY_BUFFER, object.positionArray, _gl.DYNAMIC_DRAW ); + + state.enableAttribute( programAttributes.position ); + _gl.vertexAttribPointer( programAttributes.position, 3, _gl.FLOAT, false, 0, 0 ); + + } + + if ( object.hasNormals ) { + + _gl.bindBuffer( _gl.ARRAY_BUFFER, buffers.normal ); + + if ( ! material.isMeshPhongMaterial && + ! material.isMeshStandardMaterial && + ! material.isMeshNormalMaterial && + material.flatShading === true ) { + + for ( var i = 0, l = object.count * 3; i < l; i += 9 ) { + + var array = object.normalArray; + + var nx = ( array[ i + 0 ] + array[ i + 3 ] + array[ i + 6 ] ) / 3; + var ny = ( array[ i + 1 ] + array[ i + 4 ] + array[ i + 7 ] ) / 3; + var nz = ( array[ i + 2 ] + array[ i + 5 ] + array[ i + 8 ] ) / 3; + + array[ i + 0 ] = nx; + array[ i + 1 ] = ny; + array[ i + 2 ] = nz; + + array[ i + 3 ] = nx; + array[ i + 4 ] = ny; + array[ i + 5 ] = nz; + + array[ i + 6 ] = nx; + array[ i + 7 ] = ny; + array[ i + 8 ] = nz; + + } + + } + + _gl.bufferData( _gl.ARRAY_BUFFER, object.normalArray, _gl.DYNAMIC_DRAW ); + + state.enableAttribute( programAttributes.normal ); + + _gl.vertexAttribPointer( programAttributes.normal, 3, _gl.FLOAT, false, 0, 0 ); + + } + + if ( object.hasUvs && material.map ) { + + _gl.bindBuffer( _gl.ARRAY_BUFFER, buffers.uv ); + _gl.bufferData( _gl.ARRAY_BUFFER, object.uvArray, _gl.DYNAMIC_DRAW ); + + state.enableAttribute( programAttributes.uv ); + + _gl.vertexAttribPointer( programAttributes.uv, 2, _gl.FLOAT, false, 0, 0 ); + + } + + if ( object.hasColors && material.vertexColors !== NoColors ) { + + _gl.bindBuffer( _gl.ARRAY_BUFFER, buffers.color ); + _gl.bufferData( _gl.ARRAY_BUFFER, object.colorArray, _gl.DYNAMIC_DRAW ); + + state.enableAttribute( programAttributes.color ); + + _gl.vertexAttribPointer( programAttributes.color, 3, _gl.FLOAT, false, 0, 0 ); + + } + + state.disableUnusedAttributes(); + + _gl.drawArrays( _gl.TRIANGLES, 0, object.count ); + + object.count = 0; + + }; + + this.renderBufferDirect = function ( camera, fog, geometry, material, object, group ) { + + var frontFaceCW = ( object.isMesh && object.matrixWorld.determinant() < 0 ); + + state.setMaterial( material, frontFaceCW ); + + var program = setProgram( camera, fog, material, object ); + var geometryProgram = geometry.id + '_' + program.id + '_' + ( material.wireframe === true ); + + var updateBuffers = false; + + if ( geometryProgram !== _currentGeometryProgram ) { + + _currentGeometryProgram = geometryProgram; + updateBuffers = true; + + } + + if ( object.morphTargetInfluences ) { + + morphtargets.update( object, geometry, material, program ); + + updateBuffers = true; + + } + + // + + var index = geometry.index; + var position = geometry.attributes.position; + var rangeFactor = 1; + + if ( material.wireframe === true ) { + + index = geometries.getWireframeAttribute( geometry ); + rangeFactor = 2; + + } + + var attribute; + var renderer = bufferRenderer; + + if ( index !== null ) { + + attribute = attributes.get( index ); + + renderer = indexedBufferRenderer; + renderer.setIndex( attribute ); + + } + + if ( updateBuffers ) { + + setupVertexAttributes( material, program, geometry ); + + if ( index !== null ) { + + _gl.bindBuffer( _gl.ELEMENT_ARRAY_BUFFER, attribute.buffer ); + + } + + } + + // + + var dataCount = Infinity; + + if ( index !== null ) { + + dataCount = index.count; + + } else if ( position !== undefined ) { + + dataCount = position.count; + + } + + var rangeStart = geometry.drawRange.start * rangeFactor; + var rangeCount = geometry.drawRange.count * rangeFactor; + + var groupStart = group !== null ? group.start * rangeFactor : 0; + var groupCount = group !== null ? group.count * rangeFactor : Infinity; + + var drawStart = Math.max( rangeStart, groupStart ); + var drawEnd = Math.min( dataCount, rangeStart + rangeCount, groupStart + groupCount ) - 1; + + var drawCount = Math.max( 0, drawEnd - drawStart + 1 ); + + if ( drawCount === 0 ) return; + + // + + if ( object.isMesh ) { + + if ( material.wireframe === true ) { + + state.setLineWidth( material.wireframeLinewidth * getTargetPixelRatio() ); + renderer.setMode( _gl.LINES ); + + } else { + + switch ( object.drawMode ) { + + case TrianglesDrawMode: + renderer.setMode( _gl.TRIANGLES ); + break; + + case TriangleStripDrawMode: + renderer.setMode( _gl.TRIANGLE_STRIP ); + break; + + case TriangleFanDrawMode: + renderer.setMode( _gl.TRIANGLE_FAN ); + break; + + } + + } + + + } else if ( object.isLine ) { + + var lineWidth = material.linewidth; + + if ( lineWidth === undefined ) lineWidth = 1; // Not using Line*Material + + state.setLineWidth( lineWidth * getTargetPixelRatio() ); + + if ( object.isLineSegments ) { + + renderer.setMode( _gl.LINES ); + + } else if ( object.isLineLoop ) { + + renderer.setMode( _gl.LINE_LOOP ); + + } else { + + renderer.setMode( _gl.LINE_STRIP ); + + } + + } else if ( object.isPoints ) { + + renderer.setMode( _gl.POINTS ); + + } + + if ( geometry && geometry.isInstancedBufferGeometry ) { + + if ( geometry.maxInstancedCount > 0 ) { + + renderer.renderInstances( geometry, drawStart, drawCount ); + + } + + } else { + + renderer.render( drawStart, drawCount ); + + } + + }; + + function setupVertexAttributes( material, program, geometry ) { + + if ( geometry && geometry.isInstancedBufferGeometry ) { + + if ( extensions.get( 'ANGLE_instanced_arrays' ) === null ) { + + console.error( 'THREE.WebGLRenderer.setupVertexAttributes: using THREE.InstancedBufferGeometry but hardware does not support extension ANGLE_instanced_arrays.' ); + return; + + } + + } + + state.initAttributes(); + + var geometryAttributes = geometry.attributes; + + var programAttributes = program.getAttributes(); + + var materialDefaultAttributeValues = material.defaultAttributeValues; + + for ( var name in programAttributes ) { + + var programAttribute = programAttributes[ name ]; + + if ( programAttribute >= 0 ) { + + var geometryAttribute = geometryAttributes[ name ]; + + if ( geometryAttribute !== undefined ) { + + var normalized = geometryAttribute.normalized; + var size = geometryAttribute.itemSize; + + var attribute = attributes.get( geometryAttribute ); + + // TODO Attribute may not be available on context restore + + if ( attribute === undefined ) continue; + + var buffer = attribute.buffer; + var type = attribute.type; + var bytesPerElement = attribute.bytesPerElement; + + if ( geometryAttribute.isInterleavedBufferAttribute ) { + + var data = geometryAttribute.data; + var stride = data.stride; + var offset = geometryAttribute.offset; + + if ( data && data.isInstancedInterleavedBuffer ) { + + state.enableAttributeAndDivisor( programAttribute, data.meshPerAttribute ); + + if ( geometry.maxInstancedCount === undefined ) { + + geometry.maxInstancedCount = data.meshPerAttribute * data.count; + + } + + } else { + + state.enableAttribute( programAttribute ); + + } + + _gl.bindBuffer( _gl.ARRAY_BUFFER, buffer ); + _gl.vertexAttribPointer( programAttribute, size, type, normalized, stride * bytesPerElement, offset * bytesPerElement ); + + } else { + + if ( geometryAttribute.isInstancedBufferAttribute ) { + + state.enableAttributeAndDivisor( programAttribute, geometryAttribute.meshPerAttribute ); + + if ( geometry.maxInstancedCount === undefined ) { + + geometry.maxInstancedCount = geometryAttribute.meshPerAttribute * geometryAttribute.count; + + } + + } else { + + state.enableAttribute( programAttribute ); + + } + + _gl.bindBuffer( _gl.ARRAY_BUFFER, buffer ); + _gl.vertexAttribPointer( programAttribute, size, type, normalized, 0, 0 ); + + } + + } else if ( materialDefaultAttributeValues !== undefined ) { + + var value = materialDefaultAttributeValues[ name ]; + + if ( value !== undefined ) { + + switch ( value.length ) { + + case 2: + _gl.vertexAttrib2fv( programAttribute, value ); + break; + + case 3: + _gl.vertexAttrib3fv( programAttribute, value ); + break; + + case 4: + _gl.vertexAttrib4fv( programAttribute, value ); + break; + + default: + _gl.vertexAttrib1fv( programAttribute, value ); + + } + + } + + } + + } + + } + + state.disableUnusedAttributes(); + + } + + // Compile + + this.compile = function ( scene, camera ) { + + currentRenderState = renderStates.get( scene, camera ); + currentRenderState.init(); + + scene.traverse( function ( object ) { + + if ( object.isLight ) { + + currentRenderState.pushLight( object ); + + if ( object.castShadow ) { + + currentRenderState.pushShadow( object ); + + } + + } + + } ); + + currentRenderState.setupLights( camera ); + + scene.traverse( function ( object ) { + + if ( object.material ) { + + if ( Array.isArray( object.material ) ) { + + for ( var i = 0; i < object.material.length; i ++ ) { + + initMaterial( object.material[ i ], scene.fog, object ); + + } + + } else { + + initMaterial( object.material, scene.fog, object ); + + } + + } + + } ); + + }; + + // Animation Loop + + var onAnimationFrameCallback = null; + + function onAnimationFrame() { + + if ( vr.isPresenting() ) return; + if ( onAnimationFrameCallback ) onAnimationFrameCallback(); + + } + + var animation = new WebGLAnimation(); + animation.setAnimationLoop( onAnimationFrame ); + animation.setContext( window ); + + this.setAnimationLoop = function ( callback ) { + + onAnimationFrameCallback = callback; + vr.setAnimationLoop( callback ); + + animation.start(); + + }; + + // Rendering + + this.render = function ( scene, camera, renderTarget, forceClear ) { + + if ( ! ( camera && camera.isCamera ) ) { + + console.error( 'THREE.WebGLRenderer.render: camera is not an instance of THREE.Camera.' ); + return; + + } + + if ( _isContextLost ) return; + + // reset caching for this frame + + _currentGeometryProgram = ''; + _currentMaterialId = - 1; + _currentCamera = null; + + // update scene graph + + if ( scene.autoUpdate === true ) scene.updateMatrixWorld(); + + // update camera matrices and frustum + + if ( camera.parent === null ) camera.updateMatrixWorld(); + + if ( vr.enabled ) { + + camera = vr.getCamera( camera ); + + } + + // + + currentRenderState = renderStates.get( scene, camera ); + currentRenderState.init(); + + scene.onBeforeRender( _this, scene, camera, renderTarget ); + + _projScreenMatrix.multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse ); + _frustum.setFromMatrix( _projScreenMatrix ); + + _localClippingEnabled = this.localClippingEnabled; + _clippingEnabled = _clipping.init( this.clippingPlanes, _localClippingEnabled, camera ); + + currentRenderList = renderLists.get( scene, camera ); + currentRenderList.init(); + + projectObject( scene, camera, _this.sortObjects ); + + if ( _this.sortObjects === true ) { + + currentRenderList.sort(); + + } + + // + + if ( _clippingEnabled ) _clipping.beginShadows(); + + var shadowsArray = currentRenderState.state.shadowsArray; + + shadowMap.render( shadowsArray, scene, camera ); + + currentRenderState.setupLights( camera ); + + if ( _clippingEnabled ) _clipping.endShadows(); + + // + + if ( this.info.autoReset ) this.info.reset(); + + if ( renderTarget === undefined ) { + + renderTarget = null; + + } + + this.setRenderTarget( renderTarget ); + + // + + background.render( currentRenderList, scene, camera, forceClear ); + + // render scene + + var opaqueObjects = currentRenderList.opaque; + var transparentObjects = currentRenderList.transparent; + + if ( scene.overrideMaterial ) { + + var overrideMaterial = scene.overrideMaterial; + + if ( opaqueObjects.length ) renderObjects( opaqueObjects, scene, camera, overrideMaterial ); + if ( transparentObjects.length ) renderObjects( transparentObjects, scene, camera, overrideMaterial ); + + } else { + + // opaque pass (front-to-back order) + + if ( opaqueObjects.length ) renderObjects( opaqueObjects, scene, camera ); + + // transparent pass (back-to-front order) + + if ( transparentObjects.length ) renderObjects( transparentObjects, scene, camera ); + + } + + // custom renderers + + var spritesArray = currentRenderState.state.spritesArray; + + spriteRenderer.render( spritesArray, scene, camera ); + + // Generate mipmap if we're using any kind of mipmap filtering + + if ( renderTarget ) { + + textures.updateRenderTargetMipmap( renderTarget ); + + } + + // Ensure depth buffer writing is enabled so it can be cleared on next render + + state.buffers.depth.setTest( true ); + state.buffers.depth.setMask( true ); + state.buffers.color.setMask( true ); + + state.setPolygonOffset( false ); + + scene.onAfterRender( _this, scene, camera ); + + if ( vr.enabled ) { + + vr.submitFrame(); + + } + + // _gl.finish(); + + currentRenderList = null; + currentRenderState = null; + + }; + + /* + // TODO Duplicated code (Frustum) + + var _sphere = new Sphere(); + + function isObjectViewable( object ) { + + var geometry = object.geometry; + + if ( geometry.boundingSphere === null ) + geometry.computeBoundingSphere(); + + _sphere.copy( geometry.boundingSphere ). + applyMatrix4( object.matrixWorld ); + + return isSphereViewable( _sphere ); + + } + + function isSpriteViewable( sprite ) { + + _sphere.center.set( 0, 0, 0 ); + _sphere.radius = 0.7071067811865476; + _sphere.applyMatrix4( sprite.matrixWorld ); + + return isSphereViewable( _sphere ); + + } + + function isSphereViewable( sphere ) { + + if ( ! _frustum.intersectsSphere( sphere ) ) return false; + + var numPlanes = _clipping.numPlanes; + + if ( numPlanes === 0 ) return true; + + var planes = _this.clippingPlanes, + + center = sphere.center, + negRad = - sphere.radius, + i = 0; + + do { + + // out when deeper than radius in the negative halfspace + if ( planes[ i ].distanceToPoint( center ) < negRad ) return false; + + } while ( ++ i !== numPlanes ); + + return true; + + } + */ + + function projectObject( object, camera, sortObjects ) { + + if ( object.visible === false ) return; + + var visible = object.layers.test( camera.layers ); + + if ( visible ) { + + if ( object.isLight ) { + + currentRenderState.pushLight( object ); + + if ( object.castShadow ) { + + currentRenderState.pushShadow( object ); + + } + + } else if ( object.isSprite ) { + + if ( ! object.frustumCulled || _frustum.intersectsSprite( object ) ) { + + currentRenderState.pushSprite( object ); + + } + + } else if ( object.isImmediateRenderObject ) { + + if ( sortObjects ) { + + _vector3.setFromMatrixPosition( object.matrixWorld ) + .applyMatrix4( _projScreenMatrix ); + + } + + currentRenderList.push( object, null, object.material, _vector3.z, null ); + + } else if ( object.isMesh || object.isLine || object.isPoints ) { + + if ( object.isSkinnedMesh ) { + + object.skeleton.update(); + + } + + if ( ! object.frustumCulled || _frustum.intersectsObject( object ) ) { + + if ( sortObjects ) { + + _vector3.setFromMatrixPosition( object.matrixWorld ) + .applyMatrix4( _projScreenMatrix ); + + } + + var geometry = objects.update( object ); + var material = object.material; + + if ( Array.isArray( material ) ) { + + var groups = geometry.groups; + + for ( var i = 0, l = groups.length; i < l; i ++ ) { + + var group = groups[ i ]; + var groupMaterial = material[ group.materialIndex ]; + + if ( groupMaterial && groupMaterial.visible ) { + + currentRenderList.push( object, geometry, groupMaterial, _vector3.z, group ); + + } + + } + + } else if ( material.visible ) { + + currentRenderList.push( object, geometry, material, _vector3.z, null ); + + } + + } + + } + + } + + var children = object.children; + + for ( var i = 0, l = children.length; i < l; i ++ ) { + + projectObject( children[ i ], camera, sortObjects ); + + } + + } + + function renderObjects( renderList, scene, camera, overrideMaterial ) { + + for ( var i = 0, l = renderList.length; i < l; i ++ ) { + + var renderItem = renderList[ i ]; + + var object = renderItem.object; + var geometry = renderItem.geometry; + var material = overrideMaterial === undefined ? renderItem.material : overrideMaterial; + var group = renderItem.group; + + if ( camera.isArrayCamera ) { + + _currentArrayCamera = camera; + + var cameras = camera.cameras; + + for ( var j = 0, jl = cameras.length; j < jl; j ++ ) { + + var camera2 = cameras[ j ]; + + if ( object.layers.test( camera2.layers ) ) { + + if ( 'viewport' in camera2 ) { // XR + + state.viewport( _currentViewport.copy( camera2.viewport ) ); + + } else { + + var bounds = camera2.bounds; + + var x = bounds.x * _width; + var y = bounds.y * _height; + var width = bounds.z * _width; + var height = bounds.w * _height; + + state.viewport( _currentViewport.set( x, y, width, height ).multiplyScalar( _pixelRatio ) ); + + } + + renderObject( object, scene, camera2, geometry, material, group ); + + } + + } + + } else { + + _currentArrayCamera = null; + + renderObject( object, scene, camera, geometry, material, group ); + + } + + } + + } + + function renderObject( object, scene, camera, geometry, material, group ) { + + object.onBeforeRender( _this, scene, camera, geometry, material, group ); + currentRenderState = renderStates.get( scene, _currentArrayCamera || camera ); + + object.modelViewMatrix.multiplyMatrices( camera.matrixWorldInverse, object.matrixWorld ); + object.normalMatrix.getNormalMatrix( object.modelViewMatrix ); + + if ( object.isImmediateRenderObject ) { + + var frontFaceCW = ( object.isMesh && object.matrixWorld.determinant() < 0 ); + + state.setMaterial( material, frontFaceCW ); + + var program = setProgram( camera, scene.fog, material, object ); + + _currentGeometryProgram = ''; + + renderObjectImmediate( object, program, material ); + + } else { + + _this.renderBufferDirect( camera, scene.fog, geometry, material, object, group ); + + } + + object.onAfterRender( _this, scene, camera, geometry, material, group ); + currentRenderState = renderStates.get( scene, _currentArrayCamera || camera ); + + } + + function initMaterial( material, fog, object ) { + + var materialProperties = properties.get( material ); + + var lights = currentRenderState.state.lights; + var shadowsArray = currentRenderState.state.shadowsArray; + + var parameters = programCache.getParameters( + material, lights.state, shadowsArray, fog, _clipping.numPlanes, _clipping.numIntersection, object ); + + var code = programCache.getProgramCode( material, parameters ); + + var program = materialProperties.program; + var programChange = true; + + if ( program === undefined ) { + + // new material + material.addEventListener( 'dispose', onMaterialDispose ); + + } else if ( program.code !== code ) { + + // changed glsl or parameters + releaseMaterialProgramReference( material ); + + } else if ( materialProperties.lightsHash !== lights.state.hash ) { + + properties.update( material, 'lightsHash', lights.state.hash ); + programChange = false; + + } else if ( parameters.shaderID !== undefined ) { + + // same glsl and uniform list + return; + + } else { + + // only rebuild uniform list + programChange = false; + + } + + if ( programChange ) { + + if ( parameters.shaderID ) { + + var shader = ShaderLib[ parameters.shaderID ]; + + materialProperties.shader = { + name: material.type, + uniforms: UniformsUtils.clone( shader.uniforms ), + vertexShader: shader.vertexShader, + fragmentShader: shader.fragmentShader + }; + + } else { + + materialProperties.shader = { + name: material.type, + uniforms: material.uniforms, + vertexShader: material.vertexShader, + fragmentShader: material.fragmentShader + }; + + } + + material.onBeforeCompile( materialProperties.shader, _this ); + + program = programCache.acquireProgram( material, materialProperties.shader, parameters, code ); + + materialProperties.program = program; + material.program = program; + + } + + var programAttributes = program.getAttributes(); + + if ( material.morphTargets ) { + + material.numSupportedMorphTargets = 0; + + for ( var i = 0; i < _this.maxMorphTargets; i ++ ) { + + if ( programAttributes[ 'morphTarget' + i ] >= 0 ) { + + material.numSupportedMorphTargets ++; + + } + + } + + } + + if ( material.morphNormals ) { + + material.numSupportedMorphNormals = 0; + + for ( var i = 0; i < _this.maxMorphNormals; i ++ ) { + + if ( programAttributes[ 'morphNormal' + i ] >= 0 ) { + + material.numSupportedMorphNormals ++; + + } + + } + + } + + var uniforms = materialProperties.shader.uniforms; + + if ( ! material.isShaderMaterial && + ! material.isRawShaderMaterial || + material.clipping === true ) { + + materialProperties.numClippingPlanes = _clipping.numPlanes; + materialProperties.numIntersection = _clipping.numIntersection; + uniforms.clippingPlanes = _clipping.uniform; + + } + + materialProperties.fog = fog; + + // store the light setup it was created for + + materialProperties.lightsHash = lights.state.hash; + + if ( material.lights ) { + + // wire up the material to this renderer's lighting state + + uniforms.ambientLightColor.value = lights.state.ambient; + uniforms.directionalLights.value = lights.state.directional; + uniforms.spotLights.value = lights.state.spot; + uniforms.rectAreaLights.value = lights.state.rectArea; + uniforms.pointLights.value = lights.state.point; + uniforms.hemisphereLights.value = lights.state.hemi; + + uniforms.directionalShadowMap.value = lights.state.directionalShadowMap; + uniforms.directionalShadowMatrix.value = lights.state.directionalShadowMatrix; + uniforms.spotShadowMap.value = lights.state.spotShadowMap; + uniforms.spotShadowMatrix.value = lights.state.spotShadowMatrix; + uniforms.pointShadowMap.value = lights.state.pointShadowMap; + uniforms.pointShadowMatrix.value = lights.state.pointShadowMatrix; + // TODO (abelnation): add area lights shadow info to uniforms + + } + + var progUniforms = materialProperties.program.getUniforms(), + uniformsList = + WebGLUniforms.seqWithValue( progUniforms.seq, uniforms ); + + materialProperties.uniformsList = uniformsList; + + } + + function setProgram( camera, fog, material, object ) { + + _usedTextureUnits = 0; + + var materialProperties = properties.get( material ); + var lights = currentRenderState.state.lights; + + if ( _clippingEnabled ) { + + if ( _localClippingEnabled || camera !== _currentCamera ) { + + var useCache = + camera === _currentCamera && + material.id === _currentMaterialId; + + // we might want to call this function with some ClippingGroup + // object instead of the material, once it becomes feasible + // (#8465, #8379) + _clipping.setState( + material.clippingPlanes, material.clipIntersection, material.clipShadows, + camera, materialProperties, useCache ); + + } + + } + + if ( material.needsUpdate === false ) { + + if ( materialProperties.program === undefined ) { + + material.needsUpdate = true; + + } else if ( material.fog && materialProperties.fog !== fog ) { + + material.needsUpdate = true; + + } else if ( material.lights && materialProperties.lightsHash !== lights.state.hash ) { + + material.needsUpdate = true; + + } else if ( materialProperties.numClippingPlanes !== undefined && + ( materialProperties.numClippingPlanes !== _clipping.numPlanes || + materialProperties.numIntersection !== _clipping.numIntersection ) ) { + + material.needsUpdate = true; + + } + + } + + if ( material.needsUpdate ) { + + initMaterial( material, fog, object ); + material.needsUpdate = false; + + } + + var refreshProgram = false; + var refreshMaterial = false; + var refreshLights = false; + + var program = materialProperties.program, + p_uniforms = program.getUniforms(), + m_uniforms = materialProperties.shader.uniforms; + + if ( state.useProgram( program.program ) ) { + + refreshProgram = true; + refreshMaterial = true; + refreshLights = true; + + } + + if ( material.id !== _currentMaterialId ) { + + _currentMaterialId = material.id; + + refreshMaterial = true; + + } + + if ( refreshProgram || camera !== _currentCamera ) { + + p_uniforms.setValue( _gl, 'projectionMatrix', camera.projectionMatrix ); + + if ( capabilities.logarithmicDepthBuffer ) { + + p_uniforms.setValue( _gl, 'logDepthBufFC', + 2.0 / ( Math.log( camera.far + 1.0 ) / Math.LN2 ) ); + + } + + // Avoid unneeded uniform updates per ArrayCamera's sub-camera + + if ( _currentCamera !== ( _currentArrayCamera || camera ) ) { + + _currentCamera = ( _currentArrayCamera || camera ); + + // lighting uniforms depend on the camera so enforce an update + // now, in case this material supports lights - or later, when + // the next material that does gets activated: + + refreshMaterial = true; // set to true on material change + refreshLights = true; // remains set until update done + + } + + // load material specific uniforms + // (shader material also gets them for the sake of genericity) + + if ( material.isShaderMaterial || + material.isMeshPhongMaterial || + material.isMeshStandardMaterial || + material.envMap ) { + + var uCamPos = p_uniforms.map.cameraPosition; + + if ( uCamPos !== undefined ) { + + uCamPos.setValue( _gl, + _vector3.setFromMatrixPosition( camera.matrixWorld ) ); + + } + + } + + if ( material.isMeshPhongMaterial || + material.isMeshLambertMaterial || + material.isMeshBasicMaterial || + material.isMeshStandardMaterial || + material.isShaderMaterial || + material.skinning ) { + + p_uniforms.setValue( _gl, 'viewMatrix', camera.matrixWorldInverse ); + + } + + } + + // skinning uniforms must be set even if material didn't change + // auto-setting of texture unit for bone texture must go before other textures + // not sure why, but otherwise weird things happen + + if ( material.skinning ) { + + p_uniforms.setOptional( _gl, object, 'bindMatrix' ); + p_uniforms.setOptional( _gl, object, 'bindMatrixInverse' ); + + var skeleton = object.skeleton; + + if ( skeleton ) { + + var bones = skeleton.bones; + + if ( capabilities.floatVertexTextures ) { + + if ( skeleton.boneTexture === undefined ) { + + // layout (1 matrix = 4 pixels) + // RGBA RGBA RGBA RGBA (=> column1, column2, column3, column4) + // with 8x8 pixel texture max 16 bones * 4 pixels = (8 * 8) + // 16x16 pixel texture max 64 bones * 4 pixels = (16 * 16) + // 32x32 pixel texture max 256 bones * 4 pixels = (32 * 32) + // 64x64 pixel texture max 1024 bones * 4 pixels = (64 * 64) + + + var size = Math.sqrt( bones.length * 4 ); // 4 pixels needed for 1 matrix + size = _Math.ceilPowerOfTwo( size ); + size = Math.max( size, 4 ); + + var boneMatrices = new Float32Array( size * size * 4 ); // 4 floats per RGBA pixel + boneMatrices.set( skeleton.boneMatrices ); // copy current values + + var boneTexture = new DataTexture( boneMatrices, size, size, RGBAFormat, FloatType ); + boneTexture.needsUpdate = true; + + skeleton.boneMatrices = boneMatrices; + skeleton.boneTexture = boneTexture; + skeleton.boneTextureSize = size; + + } + + p_uniforms.setValue( _gl, 'boneTexture', skeleton.boneTexture ); + p_uniforms.setValue( _gl, 'boneTextureSize', skeleton.boneTextureSize ); + + } else { + + p_uniforms.setOptional( _gl, skeleton, 'boneMatrices' ); + + } + + } + + } + + if ( refreshMaterial ) { + + p_uniforms.setValue( _gl, 'toneMappingExposure', _this.toneMappingExposure ); + p_uniforms.setValue( _gl, 'toneMappingWhitePoint', _this.toneMappingWhitePoint ); + + if ( material.lights ) { + + // the current material requires lighting info + + // note: all lighting uniforms are always set correctly + // they simply reference the renderer's state for their + // values + // + // use the current material's .needsUpdate flags to set + // the GL state when required + + markUniformsLightsNeedsUpdate( m_uniforms, refreshLights ); + + } + + // refresh uniforms common to several materials + + if ( fog && material.fog ) { + + refreshUniformsFog( m_uniforms, fog ); + + } + + if ( material.isMeshBasicMaterial ) { + + refreshUniformsCommon( m_uniforms, material ); + + } else if ( material.isMeshLambertMaterial ) { + + refreshUniformsCommon( m_uniforms, material ); + refreshUniformsLambert( m_uniforms, material ); + + } else if ( material.isMeshPhongMaterial ) { + + refreshUniformsCommon( m_uniforms, material ); + + if ( material.isMeshToonMaterial ) { + + refreshUniformsToon( m_uniforms, material ); + + } else { + + refreshUniformsPhong( m_uniforms, material ); + + } + + } else if ( material.isMeshStandardMaterial ) { + + refreshUniformsCommon( m_uniforms, material ); + + if ( material.isMeshPhysicalMaterial ) { + + refreshUniformsPhysical( m_uniforms, material ); + + } else { + + refreshUniformsStandard( m_uniforms, material ); + + } + + } else if ( material.isMeshDepthMaterial ) { + + refreshUniformsCommon( m_uniforms, material ); + refreshUniformsDepth( m_uniforms, material ); + + } else if ( material.isMeshDistanceMaterial ) { + + refreshUniformsCommon( m_uniforms, material ); + refreshUniformsDistance( m_uniforms, material ); + + } else if ( material.isMeshNormalMaterial ) { + + refreshUniformsCommon( m_uniforms, material ); + refreshUniformsNormal( m_uniforms, material ); + + } else if ( material.isLineBasicMaterial ) { + + refreshUniformsLine( m_uniforms, material ); + + if ( material.isLineDashedMaterial ) { + + refreshUniformsDash( m_uniforms, material ); + + } + + } else if ( material.isPointsMaterial ) { + + refreshUniformsPoints( m_uniforms, material ); + + } else if ( material.isShadowMaterial ) { + + m_uniforms.color.value = material.color; + m_uniforms.opacity.value = material.opacity; + + } + + // RectAreaLight Texture + // TODO (mrdoob): Find a nicer implementation + + if ( m_uniforms.ltc_1 !== undefined ) m_uniforms.ltc_1.value = UniformsLib.LTC_1; + if ( m_uniforms.ltc_2 !== undefined ) m_uniforms.ltc_2.value = UniformsLib.LTC_2; + + WebGLUniforms.upload( _gl, materialProperties.uniformsList, m_uniforms, _this ); + + } + + if ( material.isShaderMaterial && material.uniformsNeedUpdate === true ) { + + WebGLUniforms.upload( _gl, materialProperties.uniformsList, m_uniforms, _this ); + material.uniformsNeedUpdate = false; + + } + + // common matrices + + p_uniforms.setValue( _gl, 'modelViewMatrix', object.modelViewMatrix ); + p_uniforms.setValue( _gl, 'normalMatrix', object.normalMatrix ); + p_uniforms.setValue( _gl, 'modelMatrix', object.matrixWorld ); + + return program; + + } + + // Uniforms (refresh uniforms objects) + + function refreshUniformsCommon( uniforms, material ) { + + uniforms.opacity.value = material.opacity; + + if ( material.color ) { + + uniforms.diffuse.value = material.color; + + } + + if ( material.emissive ) { + + uniforms.emissive.value.copy( material.emissive ).multiplyScalar( material.emissiveIntensity ); + + } + + if ( material.map ) { + + uniforms.map.value = material.map; + + } + + if ( material.alphaMap ) { + + uniforms.alphaMap.value = material.alphaMap; + + } + + if ( material.specularMap ) { + + uniforms.specularMap.value = material.specularMap; + + } + + if ( material.envMap ) { + + uniforms.envMap.value = material.envMap; + + // don't flip CubeTexture envMaps, flip everything else: + // WebGLRenderTargetCube will be flipped for backwards compatibility + // WebGLRenderTargetCube.texture will be flipped because it's a Texture and NOT a CubeTexture + // this check must be handled differently, or removed entirely, if WebGLRenderTargetCube uses a CubeTexture in the future + uniforms.flipEnvMap.value = ( ! ( material.envMap && material.envMap.isCubeTexture ) ) ? 1 : - 1; + + uniforms.reflectivity.value = material.reflectivity; + uniforms.refractionRatio.value = material.refractionRatio; + + uniforms.maxMipLevel.value = properties.get( material.envMap ).__maxMipLevel; + + } + + if ( material.lightMap ) { + + uniforms.lightMap.value = material.lightMap; + uniforms.lightMapIntensity.value = material.lightMapIntensity; + + } + + if ( material.aoMap ) { + + uniforms.aoMap.value = material.aoMap; + uniforms.aoMapIntensity.value = material.aoMapIntensity; + + } + + // uv repeat and offset setting priorities + // 1. color map + // 2. specular map + // 3. normal map + // 4. bump map + // 5. alpha map + // 6. emissive map + + var uvScaleMap; + + if ( material.map ) { + + uvScaleMap = material.map; + + } else if ( material.specularMap ) { + + uvScaleMap = material.specularMap; + + } else if ( material.displacementMap ) { + + uvScaleMap = material.displacementMap; + + } else if ( material.normalMap ) { + + uvScaleMap = material.normalMap; + + } else if ( material.bumpMap ) { + + uvScaleMap = material.bumpMap; + + } else if ( material.roughnessMap ) { + + uvScaleMap = material.roughnessMap; + + } else if ( material.metalnessMap ) { + + uvScaleMap = material.metalnessMap; + + } else if ( material.alphaMap ) { + + uvScaleMap = material.alphaMap; + + } else if ( material.emissiveMap ) { + + uvScaleMap = material.emissiveMap; + + } + + if ( uvScaleMap !== undefined ) { + + // backwards compatibility + if ( uvScaleMap.isWebGLRenderTarget ) { + + uvScaleMap = uvScaleMap.texture; + + } + + if ( uvScaleMap.matrixAutoUpdate === true ) { + + uvScaleMap.updateMatrix(); + + } + + uniforms.uvTransform.value.copy( uvScaleMap.matrix ); + + } + + } + + function refreshUniformsLine( uniforms, material ) { + + uniforms.diffuse.value = material.color; + uniforms.opacity.value = material.opacity; + + } + + function refreshUniformsDash( uniforms, material ) { + + uniforms.dashSize.value = material.dashSize; + uniforms.totalSize.value = material.dashSize + material.gapSize; + uniforms.scale.value = material.scale; + + } + + function refreshUniformsPoints( uniforms, material ) { + + uniforms.diffuse.value = material.color; + uniforms.opacity.value = material.opacity; + uniforms.size.value = material.size * _pixelRatio; + uniforms.scale.value = _height * 0.5; + + uniforms.map.value = material.map; + + if ( material.map !== null ) { + + if ( material.map.matrixAutoUpdate === true ) { + + material.map.updateMatrix(); + + } + + uniforms.uvTransform.value.copy( material.map.matrix ); + + } + + } + + function refreshUniformsFog( uniforms, fog ) { + + uniforms.fogColor.value = fog.color; + + if ( fog.isFog ) { + + uniforms.fogNear.value = fog.near; + uniforms.fogFar.value = fog.far; + + } else if ( fog.isFogExp2 ) { + + uniforms.fogDensity.value = fog.density; + + } + + } + + function refreshUniformsLambert( uniforms, material ) { + + if ( material.emissiveMap ) { + + uniforms.emissiveMap.value = material.emissiveMap; + + } + + } + + function refreshUniformsPhong( uniforms, material ) { + + uniforms.specular.value = material.specular; + uniforms.shininess.value = Math.max( material.shininess, 1e-4 ); // to prevent pow( 0.0, 0.0 ) + + if ( material.emissiveMap ) { + + uniforms.emissiveMap.value = material.emissiveMap; + + } + + if ( material.bumpMap ) { + + uniforms.bumpMap.value = material.bumpMap; + uniforms.bumpScale.value = material.bumpScale; + if ( material.side === BackSide ) uniforms.bumpScale.value *= - 1; + + } + + if ( material.normalMap ) { + + uniforms.normalMap.value = material.normalMap; + uniforms.normalScale.value.copy( material.normalScale ); + if ( material.side === BackSide ) uniforms.normalScale.value.negate(); + + } + + if ( material.displacementMap ) { + + uniforms.displacementMap.value = material.displacementMap; + uniforms.displacementScale.value = material.displacementScale; + uniforms.displacementBias.value = material.displacementBias; + + } + + } + + function refreshUniformsToon( uniforms, material ) { + + refreshUniformsPhong( uniforms, material ); + + if ( material.gradientMap ) { + + uniforms.gradientMap.value = material.gradientMap; + + } + + } + + function refreshUniformsStandard( uniforms, material ) { + + uniforms.roughness.value = material.roughness; + uniforms.metalness.value = material.metalness; + + if ( material.roughnessMap ) { + + uniforms.roughnessMap.value = material.roughnessMap; + + } + + if ( material.metalnessMap ) { + + uniforms.metalnessMap.value = material.metalnessMap; + + } + + if ( material.emissiveMap ) { + + uniforms.emissiveMap.value = material.emissiveMap; + + } + + if ( material.bumpMap ) { + + uniforms.bumpMap.value = material.bumpMap; + uniforms.bumpScale.value = material.bumpScale; + if ( material.side === BackSide ) uniforms.bumpScale.value *= - 1; + + } + + if ( material.normalMap ) { + + uniforms.normalMap.value = material.normalMap; + uniforms.normalScale.value.copy( material.normalScale ); + if ( material.side === BackSide ) uniforms.normalScale.value.negate(); + + } + + if ( material.displacementMap ) { + + uniforms.displacementMap.value = material.displacementMap; + uniforms.displacementScale.value = material.displacementScale; + uniforms.displacementBias.value = material.displacementBias; + + } + + if ( material.envMap ) { + + //uniforms.envMap.value = material.envMap; // part of uniforms common + uniforms.envMapIntensity.value = material.envMapIntensity; + + } + + } + + function refreshUniformsPhysical( uniforms, material ) { + + uniforms.clearCoat.value = material.clearCoat; + uniforms.clearCoatRoughness.value = material.clearCoatRoughness; + + refreshUniformsStandard( uniforms, material ); + + } + + function refreshUniformsDepth( uniforms, material ) { + + if ( material.displacementMap ) { + + uniforms.displacementMap.value = material.displacementMap; + uniforms.displacementScale.value = material.displacementScale; + uniforms.displacementBias.value = material.displacementBias; + + } + + } + + function refreshUniformsDistance( uniforms, material ) { + + if ( material.displacementMap ) { + + uniforms.displacementMap.value = material.displacementMap; + uniforms.displacementScale.value = material.displacementScale; + uniforms.displacementBias.value = material.displacementBias; + + } + + uniforms.referencePosition.value.copy( material.referencePosition ); + uniforms.nearDistance.value = material.nearDistance; + uniforms.farDistance.value = material.farDistance; + + } + + function refreshUniformsNormal( uniforms, material ) { + + if ( material.bumpMap ) { + + uniforms.bumpMap.value = material.bumpMap; + uniforms.bumpScale.value = material.bumpScale; + if ( material.side === BackSide ) uniforms.bumpScale.value *= - 1; + + } + + if ( material.normalMap ) { + + uniforms.normalMap.value = material.normalMap; + uniforms.normalScale.value.copy( material.normalScale ); + if ( material.side === BackSide ) uniforms.normalScale.value.negate(); + + } + + if ( material.displacementMap ) { + + uniforms.displacementMap.value = material.displacementMap; + uniforms.displacementScale.value = material.displacementScale; + uniforms.displacementBias.value = material.displacementBias; + + } + + } + + // If uniforms are marked as clean, they don't need to be loaded to the GPU. + + function markUniformsLightsNeedsUpdate( uniforms, value ) { + + uniforms.ambientLightColor.needsUpdate = value; + + uniforms.directionalLights.needsUpdate = value; + uniforms.pointLights.needsUpdate = value; + uniforms.spotLights.needsUpdate = value; + uniforms.rectAreaLights.needsUpdate = value; + uniforms.hemisphereLights.needsUpdate = value; + + } + + // Textures + + function allocTextureUnit() { + + var textureUnit = _usedTextureUnits; + + if ( textureUnit >= capabilities.maxTextures ) { + + console.warn( 'THREE.WebGLRenderer: Trying to use ' + textureUnit + ' texture units while this GPU supports only ' + capabilities.maxTextures ); + + } + + _usedTextureUnits += 1; + + return textureUnit; + + } + + this.allocTextureUnit = allocTextureUnit; + + // this.setTexture2D = setTexture2D; + this.setTexture2D = ( function () { + + var warned = false; + + // backwards compatibility: peel texture.texture + return function setTexture2D( texture, slot ) { + + if ( texture && texture.isWebGLRenderTarget ) { + + if ( ! warned ) { + + console.warn( "THREE.WebGLRenderer.setTexture2D: don't use render targets as textures. Use their .texture property instead." ); + warned = true; + + } + + texture = texture.texture; + + } + + textures.setTexture2D( texture, slot ); + + }; + + }() ); + + this.setTexture = ( function () { + + var warned = false; + + return function setTexture( texture, slot ) { + + if ( ! warned ) { + + console.warn( "THREE.WebGLRenderer: .setTexture is deprecated, use setTexture2D instead." ); + warned = true; + + } + + textures.setTexture2D( texture, slot ); + + }; + + }() ); + + this.setTextureCube = ( function () { + + var warned = false; + + return function setTextureCube( texture, slot ) { + + // backwards compatibility: peel texture.texture + if ( texture && texture.isWebGLRenderTargetCube ) { + + if ( ! warned ) { + + console.warn( "THREE.WebGLRenderer.setTextureCube: don't use cube render targets as textures. Use their .texture property instead." ); + warned = true; + + } + + texture = texture.texture; + + } + + // currently relying on the fact that WebGLRenderTargetCube.texture is a Texture and NOT a CubeTexture + // TODO: unify these code paths + if ( ( texture && texture.isCubeTexture ) || + ( Array.isArray( texture.image ) && texture.image.length === 6 ) ) { + + // CompressedTexture can have Array in image :/ + + // this function alone should take care of cube textures + textures.setTextureCube( texture, slot ); + + } else { + + // assumed: texture property of THREE.WebGLRenderTargetCube + + textures.setTextureCubeDynamic( texture, slot ); + + } + + }; + + }() ); + + // + + this.setFramebuffer = function ( value ) { + + _framebuffer = value; + + }; + + this.getRenderTarget = function () { + + return _currentRenderTarget; + + }; + + this.setRenderTarget = function ( renderTarget ) { + + _currentRenderTarget = renderTarget; + + if ( renderTarget && properties.get( renderTarget ).__webglFramebuffer === undefined ) { + + textures.setupRenderTarget( renderTarget ); + + } + + var framebuffer = _framebuffer; + var isCube = false; + + if ( renderTarget ) { + + var __webglFramebuffer = properties.get( renderTarget ).__webglFramebuffer; + + if ( renderTarget.isWebGLRenderTargetCube ) { + + framebuffer = __webglFramebuffer[ renderTarget.activeCubeFace ]; + isCube = true; + + } else { + + framebuffer = __webglFramebuffer; + + } + + _currentViewport.copy( renderTarget.viewport ); + _currentScissor.copy( renderTarget.scissor ); + _currentScissorTest = renderTarget.scissorTest; + + } else { + + _currentViewport.copy( _viewport ).multiplyScalar( _pixelRatio ); + _currentScissor.copy( _scissor ).multiplyScalar( _pixelRatio ); + _currentScissorTest = _scissorTest; + + } + + if ( _currentFramebuffer !== framebuffer ) { + + _gl.bindFramebuffer( _gl.FRAMEBUFFER, framebuffer ); + _currentFramebuffer = framebuffer; + + } + + state.viewport( _currentViewport ); + state.scissor( _currentScissor ); + state.setScissorTest( _currentScissorTest ); + + if ( isCube ) { + + var textureProperties = properties.get( renderTarget.texture ); + _gl.framebufferTexture2D( _gl.FRAMEBUFFER, _gl.COLOR_ATTACHMENT0, _gl.TEXTURE_CUBE_MAP_POSITIVE_X + renderTarget.activeCubeFace, textureProperties.__webglTexture, renderTarget.activeMipMapLevel ); + + } + + }; + + this.readRenderTargetPixels = function ( renderTarget, x, y, width, height, buffer ) { + + if ( ! ( renderTarget && renderTarget.isWebGLRenderTarget ) ) { + + console.error( 'THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not THREE.WebGLRenderTarget.' ); + return; + + } + + var framebuffer = properties.get( renderTarget ).__webglFramebuffer; + + if ( framebuffer ) { + + var restore = false; + + if ( framebuffer !== _currentFramebuffer ) { + + _gl.bindFramebuffer( _gl.FRAMEBUFFER, framebuffer ); + + restore = true; + + } + + try { + + var texture = renderTarget.texture; + var textureFormat = texture.format; + var textureType = texture.type; + + if ( textureFormat !== RGBAFormat && utils.convert( textureFormat ) !== _gl.getParameter( _gl.IMPLEMENTATION_COLOR_READ_FORMAT ) ) { + + console.error( 'THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in RGBA or implementation defined format.' ); + return; + + } + + if ( textureType !== UnsignedByteType && utils.convert( textureType ) !== _gl.getParameter( _gl.IMPLEMENTATION_COLOR_READ_TYPE ) && // IE11, Edge and Chrome Mac < 52 (#9513) + ! ( textureType === FloatType && ( extensions.get( 'OES_texture_float' ) || extensions.get( 'WEBGL_color_buffer_float' ) ) ) && // Chrome Mac >= 52 and Firefox + ! ( textureType === HalfFloatType && extensions.get( 'EXT_color_buffer_half_float' ) ) ) { + + console.error( 'THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in UnsignedByteType or implementation defined type.' ); + return; + + } + + if ( _gl.checkFramebufferStatus( _gl.FRAMEBUFFER ) === _gl.FRAMEBUFFER_COMPLETE ) { + + // the following if statement ensures valid read requests (no out-of-bounds pixels, see #8604) + + if ( ( x >= 0 && x <= ( renderTarget.width - width ) ) && ( y >= 0 && y <= ( renderTarget.height - height ) ) ) { + + _gl.readPixels( x, y, width, height, utils.convert( textureFormat ), utils.convert( textureType ), buffer ); + + } + + } else { + + console.error( 'THREE.WebGLRenderer.readRenderTargetPixels: readPixels from renderTarget failed. Framebuffer not complete.' ); + + } + + } finally { + + if ( restore ) { + + _gl.bindFramebuffer( _gl.FRAMEBUFFER, _currentFramebuffer ); + + } + + } + + } + + }; + + this.copyFramebufferToTexture = function ( position, texture, level ) { + + var width = texture.image.width; + var height = texture.image.height; + var glFormat = utils.convert( texture.format ); + + this.setTexture2D( texture, 0 ); + + _gl.copyTexImage2D( _gl.TEXTURE_2D, level || 0, glFormat, position.x, position.y, width, height, 0 ); + + }; + + this.copyTextureToTexture = function ( position, srcTexture, dstTexture, level ) { + + var width = srcTexture.image.width; + var height = srcTexture.image.height; + var glFormat = utils.convert( dstTexture.format ); + var glType = utils.convert( dstTexture.type ); + + this.setTexture2D( dstTexture, 0 ); + + if ( srcTexture.isDataTexture ) { + + _gl.texSubImage2D( _gl.TEXTURE_2D, level || 0, position.x, position.y, width, height, glFormat, glType, srcTexture.image.data ); + + } else { + + _gl.texSubImage2D( _gl.TEXTURE_2D, level || 0, position.x, position.y, glFormat, glType, srcTexture.image ); + + } + + }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + */ + + function FogExp2( color, density ) { + + this.name = ''; + + this.color = new Color( color ); + this.density = ( density !== undefined ) ? density : 0.00025; + + } + + FogExp2.prototype.isFogExp2 = true; + + FogExp2.prototype.clone = function () { + + return new FogExp2( this.color, this.density ); + + }; + + FogExp2.prototype.toJSON = function ( /* meta */ ) { + + return { + type: 'FogExp2', + color: this.color.getHex(), + density: this.density + }; + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + */ + + function Fog( color, near, far ) { + + this.name = ''; + + this.color = new Color( color ); + + this.near = ( near !== undefined ) ? near : 1; + this.far = ( far !== undefined ) ? far : 1000; + + } + + Fog.prototype.isFog = true; + + Fog.prototype.clone = function () { + + return new Fog( this.color, this.near, this.far ); + + }; + + Fog.prototype.toJSON = function ( /* meta */ ) { + + return { + type: 'Fog', + color: this.color.getHex(), + near: this.near, + far: this.far + }; + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function Scene() { + + Object3D.call( this ); + + this.type = 'Scene'; + + this.background = null; + this.fog = null; + this.overrideMaterial = null; + + this.autoUpdate = true; // checked by the renderer + + } + + Scene.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: Scene, + + copy: function ( source, recursive ) { + + Object3D.prototype.copy.call( this, source, recursive ); + + if ( source.background !== null ) this.background = source.background.clone(); + if ( source.fog !== null ) this.fog = source.fog.clone(); + if ( source.overrideMaterial !== null ) this.overrideMaterial = source.overrideMaterial.clone(); + + this.autoUpdate = source.autoUpdate; + this.matrixAutoUpdate = source.matrixAutoUpdate; + + return this; + + }, + + toJSON: function ( meta ) { + + var data = Object3D.prototype.toJSON.call( this, meta ); + + if ( this.background !== null ) data.object.background = this.background.toJSON( meta ); + if ( this.fog !== null ) data.object.fog = this.fog.toJSON(); + + return data; + + } + + } ); + + /** + * @author alteredq / http://alteredqualia.com/ + * + * parameters = { + * color: , + * opacity: , + * map: new THREE.Texture( ), + * + * uvOffset: new THREE.Vector2(), + * uvScale: new THREE.Vector2() + * } + */ + + function SpriteMaterial( parameters ) { + + Material.call( this ); + + this.type = 'SpriteMaterial'; + + this.color = new Color( 0xffffff ); + this.map = null; + + this.rotation = 0; + + this.fog = false; + this.lights = false; + + this.setValues( parameters ); + + } + + SpriteMaterial.prototype = Object.create( Material.prototype ); + SpriteMaterial.prototype.constructor = SpriteMaterial; + SpriteMaterial.prototype.isSpriteMaterial = true; + + SpriteMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.color.copy( source.color ); + this.map = source.map; + + this.rotation = source.rotation; + + return this; + + }; + + /** + * @author mikael emtinger / http://gomo.se/ + * @author alteredq / http://alteredqualia.com/ + */ + + function Sprite( material ) { + + Object3D.call( this ); + + this.type = 'Sprite'; + + this.material = ( material !== undefined ) ? material : new SpriteMaterial(); + + this.center = new Vector2( 0.5, 0.5 ); + + } + + Sprite.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: Sprite, + + isSprite: true, + + raycast: ( function () { + + var intersectPoint = new Vector3(); + var worldPosition = new Vector3(); + var worldScale = new Vector3(); + + return function raycast( raycaster, intersects ) { + + worldPosition.setFromMatrixPosition( this.matrixWorld ); + raycaster.ray.closestPointToPoint( worldPosition, intersectPoint ); + + worldScale.setFromMatrixScale( this.matrixWorld ); + var guessSizeSq = worldScale.x * worldScale.y / 4; + + if ( worldPosition.distanceToSquared( intersectPoint ) > guessSizeSq ) return; + + var distance = raycaster.ray.origin.distanceTo( intersectPoint ); + + if ( distance < raycaster.near || distance > raycaster.far ) return; + + intersects.push( { + + distance: distance, + point: intersectPoint.clone(), + face: null, + object: this + + } ); + + }; + + }() ), + + clone: function () { + + return new this.constructor( this.material ).copy( this ); + + }, + + copy: function ( source ) { + + Object3D.prototype.copy.call( this, source ); + + if ( source.center !== undefined ) this.center.copy( source.center ); + + return this; + + } + + + } ); + + /** + * @author mikael emtinger / http://gomo.se/ + * @author alteredq / http://alteredqualia.com/ + * @author mrdoob / http://mrdoob.com/ + */ + + function LOD() { + + Object3D.call( this ); + + this.type = 'LOD'; + + Object.defineProperties( this, { + levels: { + enumerable: true, + value: [] + } + } ); + + } + + LOD.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: LOD, + + copy: function ( source ) { + + Object3D.prototype.copy.call( this, source, false ); + + var levels = source.levels; + + for ( var i = 0, l = levels.length; i < l; i ++ ) { + + var level = levels[ i ]; + + this.addLevel( level.object.clone(), level.distance ); + + } + + return this; + + }, + + addLevel: function ( object, distance ) { + + if ( distance === undefined ) distance = 0; + + distance = Math.abs( distance ); + + var levels = this.levels; + + for ( var l = 0; l < levels.length; l ++ ) { + + if ( distance < levels[ l ].distance ) { + + break; + + } + + } + + levels.splice( l, 0, { distance: distance, object: object } ); + + this.add( object ); + + }, + + getObjectForDistance: function ( distance ) { + + var levels = this.levels; + + for ( var i = 1, l = levels.length; i < l; i ++ ) { + + if ( distance < levels[ i ].distance ) { + + break; + + } + + } + + return levels[ i - 1 ].object; + + }, + + raycast: ( function () { + + var matrixPosition = new Vector3(); + + return function raycast( raycaster, intersects ) { + + matrixPosition.setFromMatrixPosition( this.matrixWorld ); + + var distance = raycaster.ray.origin.distanceTo( matrixPosition ); + + this.getObjectForDistance( distance ).raycast( raycaster, intersects ); + + }; + + }() ), + + update: function () { + + var v1 = new Vector3(); + var v2 = new Vector3(); + + return function update( camera ) { + + var levels = this.levels; + + if ( levels.length > 1 ) { + + v1.setFromMatrixPosition( camera.matrixWorld ); + v2.setFromMatrixPosition( this.matrixWorld ); + + var distance = v1.distanceTo( v2 ); + + levels[ 0 ].object.visible = true; + + for ( var i = 1, l = levels.length; i < l; i ++ ) { + + if ( distance >= levels[ i ].distance ) { + + levels[ i - 1 ].object.visible = false; + levels[ i ].object.visible = true; + + } else { + + break; + + } + + } + + for ( ; i < l; i ++ ) { + + levels[ i ].object.visible = false; + + } + + } + + }; + + }(), + + toJSON: function ( meta ) { + + var data = Object3D.prototype.toJSON.call( this, meta ); + + data.object.levels = []; + + var levels = this.levels; + + for ( var i = 0, l = levels.length; i < l; i ++ ) { + + var level = levels[ i ]; + + data.object.levels.push( { + object: level.object.uuid, + distance: level.distance + } ); + + } + + return data; + + } + + } ); + + /** + * @author mikael emtinger / http://gomo.se/ + * @author alteredq / http://alteredqualia.com/ + * @author michael guerrero / http://realitymeltdown.com + * @author ikerr / http://verold.com + */ + + function Skeleton( bones, boneInverses ) { + + // copy the bone array + + bones = bones || []; + + this.bones = bones.slice( 0 ); + this.boneMatrices = new Float32Array( this.bones.length * 16 ); + + // use the supplied bone inverses or calculate the inverses + + if ( boneInverses === undefined ) { + + this.calculateInverses(); + + } else { + + if ( this.bones.length === boneInverses.length ) { + + this.boneInverses = boneInverses.slice( 0 ); + + } else { + + console.warn( 'THREE.Skeleton boneInverses is the wrong length.' ); + + this.boneInverses = []; + + for ( var i = 0, il = this.bones.length; i < il; i ++ ) { + + this.boneInverses.push( new Matrix4() ); + + } + + } + + } + + } + + Object.assign( Skeleton.prototype, { + + calculateInverses: function () { + + this.boneInverses = []; + + for ( var i = 0, il = this.bones.length; i < il; i ++ ) { + + var inverse = new Matrix4(); + + if ( this.bones[ i ] ) { + + inverse.getInverse( this.bones[ i ].matrixWorld ); + + } + + this.boneInverses.push( inverse ); + + } + + }, + + pose: function () { + + var bone, i, il; + + // recover the bind-time world matrices + + for ( i = 0, il = this.bones.length; i < il; i ++ ) { + + bone = this.bones[ i ]; + + if ( bone ) { + + bone.matrixWorld.getInverse( this.boneInverses[ i ] ); + + } + + } + + // compute the local matrices, positions, rotations and scales + + for ( i = 0, il = this.bones.length; i < il; i ++ ) { + + bone = this.bones[ i ]; + + if ( bone ) { + + if ( bone.parent && bone.parent.isBone ) { + + bone.matrix.getInverse( bone.parent.matrixWorld ); + bone.matrix.multiply( bone.matrixWorld ); + + } else { + + bone.matrix.copy( bone.matrixWorld ); + + } + + bone.matrix.decompose( bone.position, bone.quaternion, bone.scale ); + + } + + } + + }, + + update: ( function () { + + var offsetMatrix = new Matrix4(); + var identityMatrix = new Matrix4(); + + return function update() { + + var bones = this.bones; + var boneInverses = this.boneInverses; + var boneMatrices = this.boneMatrices; + var boneTexture = this.boneTexture; + + // flatten bone matrices to array + + for ( var i = 0, il = bones.length; i < il; i ++ ) { + + // compute the offset between the current and the original transform + + var matrix = bones[ i ] ? bones[ i ].matrixWorld : identityMatrix; + + offsetMatrix.multiplyMatrices( matrix, boneInverses[ i ] ); + offsetMatrix.toArray( boneMatrices, i * 16 ); + + } + + if ( boneTexture !== undefined ) { + + boneTexture.needsUpdate = true; + + } + + }; + + } )(), + + clone: function () { + + return new Skeleton( this.bones, this.boneInverses ); + + }, + + getBoneByName: function ( name ) { + + for ( var i = 0, il = this.bones.length; i < il; i ++ ) { + + var bone = this.bones[ i ]; + + if ( bone.name === name ) { + + return bone; + + } + + } + + return undefined; + + } + + } ); + + /** + * @author mikael emtinger / http://gomo.se/ + * @author alteredq / http://alteredqualia.com/ + * @author ikerr / http://verold.com + */ + + function Bone() { + + Object3D.call( this ); + + this.type = 'Bone'; + + } + + Bone.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: Bone, + + isBone: true + + } ); + + /** + * @author mikael emtinger / http://gomo.se/ + * @author alteredq / http://alteredqualia.com/ + * @author ikerr / http://verold.com + */ + + function SkinnedMesh( geometry, material ) { + + Mesh.call( this, geometry, material ); + + this.type = 'SkinnedMesh'; + + this.bindMode = 'attached'; + this.bindMatrix = new Matrix4(); + this.bindMatrixInverse = new Matrix4(); + + var bones = this.initBones(); + var skeleton = new Skeleton( bones ); + + this.bind( skeleton, this.matrixWorld ); + + this.normalizeSkinWeights(); + + } + + SkinnedMesh.prototype = Object.assign( Object.create( Mesh.prototype ), { + + constructor: SkinnedMesh, + + isSkinnedMesh: true, + + initBones: function () { + + var bones = [], bone, gbone; + var i, il; + + if ( this.geometry && this.geometry.bones !== undefined ) { + + // first, create array of 'Bone' objects from geometry data + + for ( i = 0, il = this.geometry.bones.length; i < il; i ++ ) { + + gbone = this.geometry.bones[ i ]; + + // create new 'Bone' object + + bone = new Bone(); + bones.push( bone ); + + // apply values + + bone.name = gbone.name; + bone.position.fromArray( gbone.pos ); + bone.quaternion.fromArray( gbone.rotq ); + if ( gbone.scl !== undefined ) bone.scale.fromArray( gbone.scl ); + + } + + // second, create bone hierarchy + + for ( i = 0, il = this.geometry.bones.length; i < il; i ++ ) { + + gbone = this.geometry.bones[ i ]; + + if ( ( gbone.parent !== - 1 ) && ( gbone.parent !== null ) && ( bones[ gbone.parent ] !== undefined ) ) { + + // subsequent bones in the hierarchy + + bones[ gbone.parent ].add( bones[ i ] ); + + } else { + + // topmost bone, immediate child of the skinned mesh + + this.add( bones[ i ] ); + + } + + } + + } + + // now the bones are part of the scene graph and children of the skinned mesh. + // let's update the corresponding matrices + + this.updateMatrixWorld( true ); + + return bones; + + }, + + bind: function ( skeleton, bindMatrix ) { + + this.skeleton = skeleton; + + if ( bindMatrix === undefined ) { + + this.updateMatrixWorld( true ); + + this.skeleton.calculateInverses(); + + bindMatrix = this.matrixWorld; + + } + + this.bindMatrix.copy( bindMatrix ); + this.bindMatrixInverse.getInverse( bindMatrix ); + + }, + + pose: function () { + + this.skeleton.pose(); + + }, + + normalizeSkinWeights: function () { + + var scale, i; + + if ( this.geometry && this.geometry.isGeometry ) { + + for ( i = 0; i < this.geometry.skinWeights.length; i ++ ) { + + var sw = this.geometry.skinWeights[ i ]; + + scale = 1.0 / sw.manhattanLength(); + + if ( scale !== Infinity ) { + + sw.multiplyScalar( scale ); + + } else { + + sw.set( 1, 0, 0, 0 ); // do something reasonable + + } + + } + + } else if ( this.geometry && this.geometry.isBufferGeometry ) { + + var vec = new Vector4(); + + var skinWeight = this.geometry.attributes.skinWeight; + + for ( i = 0; i < skinWeight.count; i ++ ) { + + vec.x = skinWeight.getX( i ); + vec.y = skinWeight.getY( i ); + vec.z = skinWeight.getZ( i ); + vec.w = skinWeight.getW( i ); + + scale = 1.0 / vec.manhattanLength(); + + if ( scale !== Infinity ) { + + vec.multiplyScalar( scale ); + + } else { + + vec.set( 1, 0, 0, 0 ); // do something reasonable + + } + + skinWeight.setXYZW( i, vec.x, vec.y, vec.z, vec.w ); + + } + + } + + }, + + updateMatrixWorld: function ( force ) { + + Mesh.prototype.updateMatrixWorld.call( this, force ); + + if ( this.bindMode === 'attached' ) { + + this.bindMatrixInverse.getInverse( this.matrixWorld ); + + } else if ( this.bindMode === 'detached' ) { + + this.bindMatrixInverse.getInverse( this.bindMatrix ); + + } else { + + console.warn( 'THREE.SkinnedMesh: Unrecognized bindMode: ' + this.bindMode ); + + } + + }, + + clone: function () { + + return new this.constructor( this.geometry, this.material ).copy( this ); + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + * + * parameters = { + * color: , + * opacity: , + * + * linewidth: , + * linecap: "round", + * linejoin: "round" + * } + */ + + function LineBasicMaterial( parameters ) { + + Material.call( this ); + + this.type = 'LineBasicMaterial'; + + this.color = new Color( 0xffffff ); + + this.linewidth = 1; + this.linecap = 'round'; + this.linejoin = 'round'; + + this.lights = false; + + this.setValues( parameters ); + + } + + LineBasicMaterial.prototype = Object.create( Material.prototype ); + LineBasicMaterial.prototype.constructor = LineBasicMaterial; + + LineBasicMaterial.prototype.isLineBasicMaterial = true; + + LineBasicMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.color.copy( source.color ); + + this.linewidth = source.linewidth; + this.linecap = source.linecap; + this.linejoin = source.linejoin; + + return this; + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function Line( geometry, material, mode ) { + + if ( mode === 1 ) { + + console.warn( 'THREE.Line: parameter THREE.LinePieces no longer supported. Created THREE.LineSegments instead.' ); + return new LineSegments( geometry, material ); + + } + + Object3D.call( this ); + + this.type = 'Line'; + + this.geometry = geometry !== undefined ? geometry : new BufferGeometry(); + this.material = material !== undefined ? material : new LineBasicMaterial( { color: Math.random() * 0xffffff } ); + + } + + Line.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: Line, + + isLine: true, + + computeLineDistances: ( function () { + + var start = new Vector3(); + var end = new Vector3(); + + return function computeLineDistances() { + + var geometry = this.geometry; + + if ( geometry.isBufferGeometry ) { + + // we assume non-indexed geometry + + if ( geometry.index === null ) { + + var positionAttribute = geometry.attributes.position; + var lineDistances = [ 0 ]; + + for ( var i = 1, l = positionAttribute.count; i < l; i ++ ) { + + start.fromBufferAttribute( positionAttribute, i - 1 ); + end.fromBufferAttribute( positionAttribute, i ); + + lineDistances[ i ] = lineDistances[ i - 1 ]; + lineDistances[ i ] += start.distanceTo( end ); + + } + + geometry.addAttribute( 'lineDistance', new Float32BufferAttribute( lineDistances, 1 ) ); + + } else { + + console.warn( 'THREE.Line.computeLineDistances(): Computation only possible with non-indexed BufferGeometry.' ); + + } + + } else if ( geometry.isGeometry ) { + + var vertices = geometry.vertices; + var lineDistances = geometry.lineDistances; + + lineDistances[ 0 ] = 0; + + for ( var i = 1, l = vertices.length; i < l; i ++ ) { + + lineDistances[ i ] = lineDistances[ i - 1 ]; + lineDistances[ i ] += vertices[ i - 1 ].distanceTo( vertices[ i ] ); + + } + + } + + return this; + + }; + + }() ), + + raycast: ( function () { + + var inverseMatrix = new Matrix4(); + var ray = new Ray(); + var sphere = new Sphere(); + + return function raycast( raycaster, intersects ) { + + var precision = raycaster.linePrecision; + var precisionSq = precision * precision; + + var geometry = this.geometry; + var matrixWorld = this.matrixWorld; + + // Checking boundingSphere distance to ray + + if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere(); + + sphere.copy( geometry.boundingSphere ); + sphere.applyMatrix4( matrixWorld ); + + if ( raycaster.ray.intersectsSphere( sphere ) === false ) return; + + // + + inverseMatrix.getInverse( matrixWorld ); + ray.copy( raycaster.ray ).applyMatrix4( inverseMatrix ); + + var vStart = new Vector3(); + var vEnd = new Vector3(); + var interSegment = new Vector3(); + var interRay = new Vector3(); + var step = ( this && this.isLineSegments ) ? 2 : 1; + + if ( geometry.isBufferGeometry ) { + + var index = geometry.index; + var attributes = geometry.attributes; + var positions = attributes.position.array; + + if ( index !== null ) { + + var indices = index.array; + + for ( var i = 0, l = indices.length - 1; i < l; i += step ) { + + var a = indices[ i ]; + var b = indices[ i + 1 ]; + + vStart.fromArray( positions, a * 3 ); + vEnd.fromArray( positions, b * 3 ); + + var distSq = ray.distanceSqToSegment( vStart, vEnd, interRay, interSegment ); + + if ( distSq > precisionSq ) continue; + + interRay.applyMatrix4( this.matrixWorld ); //Move back to world space for distance calculation + + var distance = raycaster.ray.origin.distanceTo( interRay ); + + if ( distance < raycaster.near || distance > raycaster.far ) continue; + + intersects.push( { + + distance: distance, + // What do we want? intersection point on the ray or on the segment?? + // point: raycaster.ray.at( distance ), + point: interSegment.clone().applyMatrix4( this.matrixWorld ), + index: i, + face: null, + faceIndex: null, + object: this + + } ); + + } + + } else { + + for ( var i = 0, l = positions.length / 3 - 1; i < l; i += step ) { + + vStart.fromArray( positions, 3 * i ); + vEnd.fromArray( positions, 3 * i + 3 ); + + var distSq = ray.distanceSqToSegment( vStart, vEnd, interRay, interSegment ); + + if ( distSq > precisionSq ) continue; + + interRay.applyMatrix4( this.matrixWorld ); //Move back to world space for distance calculation + + var distance = raycaster.ray.origin.distanceTo( interRay ); + + if ( distance < raycaster.near || distance > raycaster.far ) continue; + + intersects.push( { + + distance: distance, + // What do we want? intersection point on the ray or on the segment?? + // point: raycaster.ray.at( distance ), + point: interSegment.clone().applyMatrix4( this.matrixWorld ), + index: i, + face: null, + faceIndex: null, + object: this + + } ); + + } + + } + + } else if ( geometry.isGeometry ) { + + var vertices = geometry.vertices; + var nbVertices = vertices.length; + + for ( var i = 0; i < nbVertices - 1; i += step ) { + + var distSq = ray.distanceSqToSegment( vertices[ i ], vertices[ i + 1 ], interRay, interSegment ); + + if ( distSq > precisionSq ) continue; + + interRay.applyMatrix4( this.matrixWorld ); //Move back to world space for distance calculation + + var distance = raycaster.ray.origin.distanceTo( interRay ); + + if ( distance < raycaster.near || distance > raycaster.far ) continue; + + intersects.push( { + + distance: distance, + // What do we want? intersection point on the ray or on the segment?? + // point: raycaster.ray.at( distance ), + point: interSegment.clone().applyMatrix4( this.matrixWorld ), + index: i, + face: null, + faceIndex: null, + object: this + + } ); + + } + + } + + }; + + }() ), + + clone: function () { + + return new this.constructor( this.geometry, this.material ).copy( this ); + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function LineSegments( geometry, material ) { + + Line.call( this, geometry, material ); + + this.type = 'LineSegments'; + + } + + LineSegments.prototype = Object.assign( Object.create( Line.prototype ), { + + constructor: LineSegments, + + isLineSegments: true, + + computeLineDistances: ( function () { + + var start = new Vector3(); + var end = new Vector3(); + + return function computeLineDistances() { + + var geometry = this.geometry; + + if ( geometry.isBufferGeometry ) { + + // we assume non-indexed geometry + + if ( geometry.index === null ) { + + var positionAttribute = geometry.attributes.position; + var lineDistances = []; + + for ( var i = 0, l = positionAttribute.count; i < l; i += 2 ) { + + start.fromBufferAttribute( positionAttribute, i ); + end.fromBufferAttribute( positionAttribute, i + 1 ); + + lineDistances[ i ] = ( i === 0 ) ? 0 : lineDistances[ i - 1 ]; + lineDistances[ i + 1 ] = lineDistances[ i ] + start.distanceTo( end ); + + } + + geometry.addAttribute( 'lineDistance', new Float32BufferAttribute( lineDistances, 1 ) ); + + } else { + + console.warn( 'THREE.LineSegments.computeLineDistances(): Computation only possible with non-indexed BufferGeometry.' ); + + } + + } else if ( geometry.isGeometry ) { + + var vertices = geometry.vertices; + var lineDistances = geometry.lineDistances; + + for ( var i = 0, l = vertices.length; i < l; i += 2 ) { + + start.copy( vertices[ i ] ); + end.copy( vertices[ i + 1 ] ); + + lineDistances[ i ] = ( i === 0 ) ? 0 : lineDistances[ i - 1 ]; + lineDistances[ i + 1 ] = lineDistances[ i ] + start.distanceTo( end ); + + } + + } + + return this; + + }; + + }() ) + + } ); + + /** + * @author mgreter / http://github.com/mgreter + */ + + function LineLoop( geometry, material ) { + + Line.call( this, geometry, material ); + + this.type = 'LineLoop'; + + } + + LineLoop.prototype = Object.assign( Object.create( Line.prototype ), { + + constructor: LineLoop, + + isLineLoop: true, + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + * + * parameters = { + * color: , + * opacity: , + * map: new THREE.Texture( ), + * + * size: , + * sizeAttenuation: + * + * morphTargets: + * } + */ + + function PointsMaterial( parameters ) { + + Material.call( this ); + + this.type = 'PointsMaterial'; + + this.color = new Color( 0xffffff ); + + this.map = null; + + this.size = 1; + this.sizeAttenuation = true; + + this.morphTargets = false; + + this.lights = false; + + this.setValues( parameters ); + + } + + PointsMaterial.prototype = Object.create( Material.prototype ); + PointsMaterial.prototype.constructor = PointsMaterial; + + PointsMaterial.prototype.isPointsMaterial = true; + + PointsMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.color.copy( source.color ); + + this.map = source.map; + + this.size = source.size; + this.sizeAttenuation = source.sizeAttenuation; + + this.morphTargets = source.morphTargets; + + return this; + + }; + + /** + * @author alteredq / http://alteredqualia.com/ + */ + + function Points( geometry, material ) { + + Object3D.call( this ); + + this.type = 'Points'; + + this.geometry = geometry !== undefined ? geometry : new BufferGeometry(); + this.material = material !== undefined ? material : new PointsMaterial( { color: Math.random() * 0xffffff } ); + + } + + Points.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: Points, + + isPoints: true, + + raycast: ( function () { + + var inverseMatrix = new Matrix4(); + var ray = new Ray(); + var sphere = new Sphere(); + + return function raycast( raycaster, intersects ) { + + var object = this; + var geometry = this.geometry; + var matrixWorld = this.matrixWorld; + var threshold = raycaster.params.Points.threshold; + + // Checking boundingSphere distance to ray + + if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere(); + + sphere.copy( geometry.boundingSphere ); + sphere.applyMatrix4( matrixWorld ); + sphere.radius += threshold; + + if ( raycaster.ray.intersectsSphere( sphere ) === false ) return; + + // + + inverseMatrix.getInverse( matrixWorld ); + ray.copy( raycaster.ray ).applyMatrix4( inverseMatrix ); + + var localThreshold = threshold / ( ( this.scale.x + this.scale.y + this.scale.z ) / 3 ); + var localThresholdSq = localThreshold * localThreshold; + var position = new Vector3(); + var intersectPoint = new Vector3(); + + function testPoint( point, index ) { + + var rayPointDistanceSq = ray.distanceSqToPoint( point ); + + if ( rayPointDistanceSq < localThresholdSq ) { + + ray.closestPointToPoint( point, intersectPoint ); + intersectPoint.applyMatrix4( matrixWorld ); + + var distance = raycaster.ray.origin.distanceTo( intersectPoint ); + + if ( distance < raycaster.near || distance > raycaster.far ) return; + + intersects.push( { + + distance: distance, + distanceToRay: Math.sqrt( rayPointDistanceSq ), + point: intersectPoint.clone(), + index: index, + face: null, + object: object + + } ); + + } + + } + + if ( geometry.isBufferGeometry ) { + + var index = geometry.index; + var attributes = geometry.attributes; + var positions = attributes.position.array; + + if ( index !== null ) { + + var indices = index.array; + + for ( var i = 0, il = indices.length; i < il; i ++ ) { + + var a = indices[ i ]; + + position.fromArray( positions, a * 3 ); + + testPoint( position, a ); + + } + + } else { + + for ( var i = 0, l = positions.length / 3; i < l; i ++ ) { + + position.fromArray( positions, i * 3 ); + + testPoint( position, i ); + + } + + } + + } else { + + var vertices = geometry.vertices; + + for ( var i = 0, l = vertices.length; i < l; i ++ ) { + + testPoint( vertices[ i ], i ); + + } + + } + + }; + + }() ), + + clone: function () { + + return new this.constructor( this.geometry, this.material ).copy( this ); + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function Group() { + + Object3D.call( this ); + + this.type = 'Group'; + + } + + Group.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: Group, + + isGroup: true + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function VideoTexture( video, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy ) { + + Texture.call( this, video, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy ); + + this.generateMipmaps = false; + + } + + VideoTexture.prototype = Object.assign( Object.create( Texture.prototype ), { + + constructor: VideoTexture, + + isVideoTexture: true, + + update: function () { + + var video = this.image; + + if ( video.readyState >= video.HAVE_CURRENT_DATA ) { + + this.needsUpdate = true; + + } + + } + + } ); + + /** + * @author alteredq / http://alteredqualia.com/ + */ + + function CompressedTexture( mipmaps, width, height, format, type, mapping, wrapS, wrapT, magFilter, minFilter, anisotropy, encoding ) { + + Texture.call( this, null, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy, encoding ); + + this.image = { width: width, height: height }; + this.mipmaps = mipmaps; + + // no flipping for cube textures + // (also flipping doesn't work for compressed textures ) + + this.flipY = false; + + // can't generate mipmaps for compressed textures + // mips must be embedded in DDS files + + this.generateMipmaps = false; + + } + + CompressedTexture.prototype = Object.create( Texture.prototype ); + CompressedTexture.prototype.constructor = CompressedTexture; + + CompressedTexture.prototype.isCompressedTexture = true; + + /** + * @author Matt DesLauriers / @mattdesl + * @author atix / arthursilber.de + */ + + function DepthTexture( width, height, type, mapping, wrapS, wrapT, magFilter, minFilter, anisotropy, format ) { + + format = format !== undefined ? format : DepthFormat; + + if ( format !== DepthFormat && format !== DepthStencilFormat ) { + + throw new Error( 'DepthTexture format must be either THREE.DepthFormat or THREE.DepthStencilFormat' ); + + } + + if ( type === undefined && format === DepthFormat ) type = UnsignedShortType; + if ( type === undefined && format === DepthStencilFormat ) type = UnsignedInt248Type; + + Texture.call( this, null, mapping, wrapS, wrapT, magFilter, minFilter, format, type, anisotropy ); + + this.image = { width: width, height: height }; + + this.magFilter = magFilter !== undefined ? magFilter : NearestFilter; + this.minFilter = minFilter !== undefined ? minFilter : NearestFilter; + + this.flipY = false; + this.generateMipmaps = false; + + } + + DepthTexture.prototype = Object.create( Texture.prototype ); + DepthTexture.prototype.constructor = DepthTexture; + DepthTexture.prototype.isDepthTexture = true; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author Mugen87 / https://github.com/Mugen87 + */ + + function WireframeGeometry( geometry ) { + + BufferGeometry.call( this ); + + this.type = 'WireframeGeometry'; + + // buffer + + var vertices = []; + + // helper variables + + var i, j, l, o, ol; + var edge = [ 0, 0 ], edges = {}, e, edge1, edge2; + var key, keys = [ 'a', 'b', 'c' ]; + var vertex; + + // different logic for Geometry and BufferGeometry + + if ( geometry && geometry.isGeometry ) { + + // create a data structure that contains all edges without duplicates + + var faces = geometry.faces; + + for ( i = 0, l = faces.length; i < l; i ++ ) { + + var face = faces[ i ]; + + for ( j = 0; j < 3; j ++ ) { + + edge1 = face[ keys[ j ] ]; + edge2 = face[ keys[ ( j + 1 ) % 3 ] ]; + edge[ 0 ] = Math.min( edge1, edge2 ); // sorting prevents duplicates + edge[ 1 ] = Math.max( edge1, edge2 ); + + key = edge[ 0 ] + ',' + edge[ 1 ]; + + if ( edges[ key ] === undefined ) { + + edges[ key ] = { index1: edge[ 0 ], index2: edge[ 1 ] }; + + } + + } + + } + + // generate vertices + + for ( key in edges ) { + + e = edges[ key ]; + + vertex = geometry.vertices[ e.index1 ]; + vertices.push( vertex.x, vertex.y, vertex.z ); + + vertex = geometry.vertices[ e.index2 ]; + vertices.push( vertex.x, vertex.y, vertex.z ); + + } + + } else if ( geometry && geometry.isBufferGeometry ) { + + var position, indices, groups; + var group, start, count; + var index1, index2; + + vertex = new Vector3(); + + if ( geometry.index !== null ) { + + // indexed BufferGeometry + + position = geometry.attributes.position; + indices = geometry.index; + groups = geometry.groups; + + if ( groups.length === 0 ) { + + groups = [ { start: 0, count: indices.count, materialIndex: 0 } ]; + + } + + // create a data structure that contains all eges without duplicates + + for ( o = 0, ol = groups.length; o < ol; ++ o ) { + + group = groups[ o ]; + + start = group.start; + count = group.count; + + for ( i = start, l = ( start + count ); i < l; i += 3 ) { + + for ( j = 0; j < 3; j ++ ) { + + edge1 = indices.getX( i + j ); + edge2 = indices.getX( i + ( j + 1 ) % 3 ); + edge[ 0 ] = Math.min( edge1, edge2 ); // sorting prevents duplicates + edge[ 1 ] = Math.max( edge1, edge2 ); + + key = edge[ 0 ] + ',' + edge[ 1 ]; + + if ( edges[ key ] === undefined ) { + + edges[ key ] = { index1: edge[ 0 ], index2: edge[ 1 ] }; + + } + + } + + } + + } + + // generate vertices + + for ( key in edges ) { + + e = edges[ key ]; + + vertex.fromBufferAttribute( position, e.index1 ); + vertices.push( vertex.x, vertex.y, vertex.z ); + + vertex.fromBufferAttribute( position, e.index2 ); + vertices.push( vertex.x, vertex.y, vertex.z ); + + } + + } else { + + // non-indexed BufferGeometry + + position = geometry.attributes.position; + + for ( i = 0, l = ( position.count / 3 ); i < l; i ++ ) { + + for ( j = 0; j < 3; j ++ ) { + + // three edges per triangle, an edge is represented as (index1, index2) + // e.g. the first triangle has the following edges: (0,1),(1,2),(2,0) + + index1 = 3 * i + j; + vertex.fromBufferAttribute( position, index1 ); + vertices.push( vertex.x, vertex.y, vertex.z ); + + index2 = 3 * i + ( ( j + 1 ) % 3 ); + vertex.fromBufferAttribute( position, index2 ); + vertices.push( vertex.x, vertex.y, vertex.z ); + + } + + } + + } + + } + + // build geometry + + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + + } + + WireframeGeometry.prototype = Object.create( BufferGeometry.prototype ); + WireframeGeometry.prototype.constructor = WireframeGeometry; + + /** + * @author zz85 / https://github.com/zz85 + * @author Mugen87 / https://github.com/Mugen87 + * + * Parametric Surfaces Geometry + * based on the brilliant article by @prideout http://prideout.net/blog/?p=44 + */ + + // ParametricGeometry + + function ParametricGeometry( func, slices, stacks ) { + + Geometry.call( this ); + + this.type = 'ParametricGeometry'; + + this.parameters = { + func: func, + slices: slices, + stacks: stacks + }; + + this.fromBufferGeometry( new ParametricBufferGeometry( func, slices, stacks ) ); + this.mergeVertices(); + + } + + ParametricGeometry.prototype = Object.create( Geometry.prototype ); + ParametricGeometry.prototype.constructor = ParametricGeometry; + + // ParametricBufferGeometry + + function ParametricBufferGeometry( func, slices, stacks ) { + + BufferGeometry.call( this ); + + this.type = 'ParametricBufferGeometry'; + + this.parameters = { + func: func, + slices: slices, + stacks: stacks + }; + + // buffers + + var indices = []; + var vertices = []; + var normals = []; + var uvs = []; + + var EPS = 0.00001; + + var normal = new Vector3(); + + var p0 = new Vector3(), p1 = new Vector3(); + var pu = new Vector3(), pv = new Vector3(); + + var i, j; + + if ( func.length < 3 ) { + + console.error( 'THREE.ParametricGeometry: Function must now modify a Vector3 as third parameter.' ); + + } + + // generate vertices, normals and uvs + + var sliceCount = slices + 1; + + for ( i = 0; i <= stacks; i ++ ) { + + var v = i / stacks; + + for ( j = 0; j <= slices; j ++ ) { + + var u = j / slices; + + // vertex + + func( u, v, p0 ); + vertices.push( p0.x, p0.y, p0.z ); + + // normal + + // approximate tangent vectors via finite differences + + if ( u - EPS >= 0 ) { + + func( u - EPS, v, p1 ); + pu.subVectors( p0, p1 ); + + } else { + + func( u + EPS, v, p1 ); + pu.subVectors( p1, p0 ); + + } + + if ( v - EPS >= 0 ) { + + func( u, v - EPS, p1 ); + pv.subVectors( p0, p1 ); + + } else { + + func( u, v + EPS, p1 ); + pv.subVectors( p1, p0 ); + + } + + // cross product of tangent vectors returns surface normal + + normal.crossVectors( pu, pv ).normalize(); + normals.push( normal.x, normal.y, normal.z ); + + // uv + + uvs.push( u, v ); + + } + + } + + // generate indices + + for ( i = 0; i < stacks; i ++ ) { + + for ( j = 0; j < slices; j ++ ) { + + var a = i * sliceCount + j; + var b = i * sliceCount + j + 1; + var c = ( i + 1 ) * sliceCount + j + 1; + var d = ( i + 1 ) * sliceCount + j; + + // faces one and two + + indices.push( a, b, d ); + indices.push( b, c, d ); + + } + + } + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + } + + ParametricBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + ParametricBufferGeometry.prototype.constructor = ParametricBufferGeometry; + + /** + * @author clockworkgeek / https://github.com/clockworkgeek + * @author timothypratley / https://github.com/timothypratley + * @author WestLangley / http://github.com/WestLangley + * @author Mugen87 / https://github.com/Mugen87 + */ + + // PolyhedronGeometry + + function PolyhedronGeometry( vertices, indices, radius, detail ) { + + Geometry.call( this ); + + this.type = 'PolyhedronGeometry'; + + this.parameters = { + vertices: vertices, + indices: indices, + radius: radius, + detail: detail + }; + + this.fromBufferGeometry( new PolyhedronBufferGeometry( vertices, indices, radius, detail ) ); + this.mergeVertices(); + + } + + PolyhedronGeometry.prototype = Object.create( Geometry.prototype ); + PolyhedronGeometry.prototype.constructor = PolyhedronGeometry; + + // PolyhedronBufferGeometry + + function PolyhedronBufferGeometry( vertices, indices, radius, detail ) { + + BufferGeometry.call( this ); + + this.type = 'PolyhedronBufferGeometry'; + + this.parameters = { + vertices: vertices, + indices: indices, + radius: radius, + detail: detail + }; + + radius = radius || 1; + detail = detail || 0; + + // default buffer data + + var vertexBuffer = []; + var uvBuffer = []; + + // the subdivision creates the vertex buffer data + + subdivide( detail ); + + // all vertices should lie on a conceptual sphere with a given radius + + appplyRadius( radius ); + + // finally, create the uv data + + generateUVs(); + + // build non-indexed geometry + + this.addAttribute( 'position', new Float32BufferAttribute( vertexBuffer, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( vertexBuffer.slice(), 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvBuffer, 2 ) ); + + if ( detail === 0 ) { + + this.computeVertexNormals(); // flat normals + + } else { + + this.normalizeNormals(); // smooth normals + + } + + // helper functions + + function subdivide( detail ) { + + var a = new Vector3(); + var b = new Vector3(); + var c = new Vector3(); + + // iterate over all faces and apply a subdivison with the given detail value + + for ( var i = 0; i < indices.length; i += 3 ) { + + // get the vertices of the face + + getVertexByIndex( indices[ i + 0 ], a ); + getVertexByIndex( indices[ i + 1 ], b ); + getVertexByIndex( indices[ i + 2 ], c ); + + // perform subdivision + + subdivideFace( a, b, c, detail ); + + } + + } + + function subdivideFace( a, b, c, detail ) { + + var cols = Math.pow( 2, detail ); + + // we use this multidimensional array as a data structure for creating the subdivision + + var v = []; + + var i, j; + + // construct all of the vertices for this subdivision + + for ( i = 0; i <= cols; i ++ ) { + + v[ i ] = []; + + var aj = a.clone().lerp( c, i / cols ); + var bj = b.clone().lerp( c, i / cols ); + + var rows = cols - i; + + for ( j = 0; j <= rows; j ++ ) { + + if ( j === 0 && i === cols ) { + + v[ i ][ j ] = aj; + + } else { + + v[ i ][ j ] = aj.clone().lerp( bj, j / rows ); + + } + + } + + } + + // construct all of the faces + + for ( i = 0; i < cols; i ++ ) { + + for ( j = 0; j < 2 * ( cols - i ) - 1; j ++ ) { + + var k = Math.floor( j / 2 ); + + if ( j % 2 === 0 ) { + + pushVertex( v[ i ][ k + 1 ] ); + pushVertex( v[ i + 1 ][ k ] ); + pushVertex( v[ i ][ k ] ); + + } else { + + pushVertex( v[ i ][ k + 1 ] ); + pushVertex( v[ i + 1 ][ k + 1 ] ); + pushVertex( v[ i + 1 ][ k ] ); + + } + + } + + } + + } + + function appplyRadius( radius ) { + + var vertex = new Vector3(); + + // iterate over the entire buffer and apply the radius to each vertex + + for ( var i = 0; i < vertexBuffer.length; i += 3 ) { + + vertex.x = vertexBuffer[ i + 0 ]; + vertex.y = vertexBuffer[ i + 1 ]; + vertex.z = vertexBuffer[ i + 2 ]; + + vertex.normalize().multiplyScalar( radius ); + + vertexBuffer[ i + 0 ] = vertex.x; + vertexBuffer[ i + 1 ] = vertex.y; + vertexBuffer[ i + 2 ] = vertex.z; + + } + + } + + function generateUVs() { + + var vertex = new Vector3(); + + for ( var i = 0; i < vertexBuffer.length; i += 3 ) { + + vertex.x = vertexBuffer[ i + 0 ]; + vertex.y = vertexBuffer[ i + 1 ]; + vertex.z = vertexBuffer[ i + 2 ]; + + var u = azimuth( vertex ) / 2 / Math.PI + 0.5; + var v = inclination( vertex ) / Math.PI + 0.5; + uvBuffer.push( u, 1 - v ); + + } + + correctUVs(); + + correctSeam(); + + } + + function correctSeam() { + + // handle case when face straddles the seam, see #3269 + + for ( var i = 0; i < uvBuffer.length; i += 6 ) { + + // uv data of a single face + + var x0 = uvBuffer[ i + 0 ]; + var x1 = uvBuffer[ i + 2 ]; + var x2 = uvBuffer[ i + 4 ]; + + var max = Math.max( x0, x1, x2 ); + var min = Math.min( x0, x1, x2 ); + + // 0.9 is somewhat arbitrary + + if ( max > 0.9 && min < 0.1 ) { + + if ( x0 < 0.2 ) uvBuffer[ i + 0 ] += 1; + if ( x1 < 0.2 ) uvBuffer[ i + 2 ] += 1; + if ( x2 < 0.2 ) uvBuffer[ i + 4 ] += 1; + + } + + } + + } + + function pushVertex( vertex ) { + + vertexBuffer.push( vertex.x, vertex.y, vertex.z ); + + } + + function getVertexByIndex( index, vertex ) { + + var stride = index * 3; + + vertex.x = vertices[ stride + 0 ]; + vertex.y = vertices[ stride + 1 ]; + vertex.z = vertices[ stride + 2 ]; + + } + + function correctUVs() { + + var a = new Vector3(); + var b = new Vector3(); + var c = new Vector3(); + + var centroid = new Vector3(); + + var uvA = new Vector2(); + var uvB = new Vector2(); + var uvC = new Vector2(); + + for ( var i = 0, j = 0; i < vertexBuffer.length; i += 9, j += 6 ) { + + a.set( vertexBuffer[ i + 0 ], vertexBuffer[ i + 1 ], vertexBuffer[ i + 2 ] ); + b.set( vertexBuffer[ i + 3 ], vertexBuffer[ i + 4 ], vertexBuffer[ i + 5 ] ); + c.set( vertexBuffer[ i + 6 ], vertexBuffer[ i + 7 ], vertexBuffer[ i + 8 ] ); + + uvA.set( uvBuffer[ j + 0 ], uvBuffer[ j + 1 ] ); + uvB.set( uvBuffer[ j + 2 ], uvBuffer[ j + 3 ] ); + uvC.set( uvBuffer[ j + 4 ], uvBuffer[ j + 5 ] ); + + centroid.copy( a ).add( b ).add( c ).divideScalar( 3 ); + + var azi = azimuth( centroid ); + + correctUV( uvA, j + 0, a, azi ); + correctUV( uvB, j + 2, b, azi ); + correctUV( uvC, j + 4, c, azi ); + + } + + } + + function correctUV( uv, stride, vector, azimuth ) { + + if ( ( azimuth < 0 ) && ( uv.x === 1 ) ) { + + uvBuffer[ stride ] = uv.x - 1; + + } + + if ( ( vector.x === 0 ) && ( vector.z === 0 ) ) { + + uvBuffer[ stride ] = azimuth / 2 / Math.PI + 0.5; + + } + + } + + // Angle around the Y axis, counter-clockwise when looking from above. + + function azimuth( vector ) { + + return Math.atan2( vector.z, - vector.x ); + + } + + + // Angle above the XZ plane. + + function inclination( vector ) { + + return Math.atan2( - vector.y, Math.sqrt( ( vector.x * vector.x ) + ( vector.z * vector.z ) ) ); + + } + + } + + PolyhedronBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + PolyhedronBufferGeometry.prototype.constructor = PolyhedronBufferGeometry; + + /** + * @author timothypratley / https://github.com/timothypratley + * @author Mugen87 / https://github.com/Mugen87 + */ + + // TetrahedronGeometry + + function TetrahedronGeometry( radius, detail ) { + + Geometry.call( this ); + + this.type = 'TetrahedronGeometry'; + + this.parameters = { + radius: radius, + detail: detail + }; + + this.fromBufferGeometry( new TetrahedronBufferGeometry( radius, detail ) ); + this.mergeVertices(); + + } + + TetrahedronGeometry.prototype = Object.create( Geometry.prototype ); + TetrahedronGeometry.prototype.constructor = TetrahedronGeometry; + + // TetrahedronBufferGeometry + + function TetrahedronBufferGeometry( radius, detail ) { + + var vertices = [ + 1, 1, 1, - 1, - 1, 1, - 1, 1, - 1, 1, - 1, - 1 + ]; + + var indices = [ + 2, 1, 0, 0, 3, 2, 1, 3, 0, 2, 3, 1 + ]; + + PolyhedronBufferGeometry.call( this, vertices, indices, radius, detail ); + + this.type = 'TetrahedronBufferGeometry'; + + this.parameters = { + radius: radius, + detail: detail + }; + + } + + TetrahedronBufferGeometry.prototype = Object.create( PolyhedronBufferGeometry.prototype ); + TetrahedronBufferGeometry.prototype.constructor = TetrahedronBufferGeometry; + + /** + * @author timothypratley / https://github.com/timothypratley + * @author Mugen87 / https://github.com/Mugen87 + */ + + // OctahedronGeometry + + function OctahedronGeometry( radius, detail ) { + + Geometry.call( this ); + + this.type = 'OctahedronGeometry'; + + this.parameters = { + radius: radius, + detail: detail + }; + + this.fromBufferGeometry( new OctahedronBufferGeometry( radius, detail ) ); + this.mergeVertices(); + + } + + OctahedronGeometry.prototype = Object.create( Geometry.prototype ); + OctahedronGeometry.prototype.constructor = OctahedronGeometry; + + // OctahedronBufferGeometry + + function OctahedronBufferGeometry( radius, detail ) { + + var vertices = [ + 1, 0, 0, - 1, 0, 0, 0, 1, 0, + 0, - 1, 0, 0, 0, 1, 0, 0, - 1 + ]; + + var indices = [ + 0, 2, 4, 0, 4, 3, 0, 3, 5, + 0, 5, 2, 1, 2, 5, 1, 5, 3, + 1, 3, 4, 1, 4, 2 + ]; + + PolyhedronBufferGeometry.call( this, vertices, indices, radius, detail ); + + this.type = 'OctahedronBufferGeometry'; + + this.parameters = { + radius: radius, + detail: detail + }; + + } + + OctahedronBufferGeometry.prototype = Object.create( PolyhedronBufferGeometry.prototype ); + OctahedronBufferGeometry.prototype.constructor = OctahedronBufferGeometry; + + /** + * @author timothypratley / https://github.com/timothypratley + * @author Mugen87 / https://github.com/Mugen87 + */ + + // IcosahedronGeometry + + function IcosahedronGeometry( radius, detail ) { + + Geometry.call( this ); + + this.type = 'IcosahedronGeometry'; + + this.parameters = { + radius: radius, + detail: detail + }; + + this.fromBufferGeometry( new IcosahedronBufferGeometry( radius, detail ) ); + this.mergeVertices(); + + } + + IcosahedronGeometry.prototype = Object.create( Geometry.prototype ); + IcosahedronGeometry.prototype.constructor = IcosahedronGeometry; + + // IcosahedronBufferGeometry + + function IcosahedronBufferGeometry( radius, detail ) { + + var t = ( 1 + Math.sqrt( 5 ) ) / 2; + + var vertices = [ + - 1, t, 0, 1, t, 0, - 1, - t, 0, 1, - t, 0, + 0, - 1, t, 0, 1, t, 0, - 1, - t, 0, 1, - t, + t, 0, - 1, t, 0, 1, - t, 0, - 1, - t, 0, 1 + ]; + + var indices = [ + 0, 11, 5, 0, 5, 1, 0, 1, 7, 0, 7, 10, 0, 10, 11, + 1, 5, 9, 5, 11, 4, 11, 10, 2, 10, 7, 6, 7, 1, 8, + 3, 9, 4, 3, 4, 2, 3, 2, 6, 3, 6, 8, 3, 8, 9, + 4, 9, 5, 2, 4, 11, 6, 2, 10, 8, 6, 7, 9, 8, 1 + ]; + + PolyhedronBufferGeometry.call( this, vertices, indices, radius, detail ); + + this.type = 'IcosahedronBufferGeometry'; + + this.parameters = { + radius: radius, + detail: detail + }; + + } + + IcosahedronBufferGeometry.prototype = Object.create( PolyhedronBufferGeometry.prototype ); + IcosahedronBufferGeometry.prototype.constructor = IcosahedronBufferGeometry; + + /** + * @author Abe Pazos / https://hamoid.com + * @author Mugen87 / https://github.com/Mugen87 + */ + + // DodecahedronGeometry + + function DodecahedronGeometry( radius, detail ) { + + Geometry.call( this ); + + this.type = 'DodecahedronGeometry'; + + this.parameters = { + radius: radius, + detail: detail + }; + + this.fromBufferGeometry( new DodecahedronBufferGeometry( radius, detail ) ); + this.mergeVertices(); + + } + + DodecahedronGeometry.prototype = Object.create( Geometry.prototype ); + DodecahedronGeometry.prototype.constructor = DodecahedronGeometry; + + // DodecahedronBufferGeometry + + function DodecahedronBufferGeometry( radius, detail ) { + + var t = ( 1 + Math.sqrt( 5 ) ) / 2; + var r = 1 / t; + + var vertices = [ + + // (±1, ±1, ±1) + - 1, - 1, - 1, - 1, - 1, 1, + - 1, 1, - 1, - 1, 1, 1, + 1, - 1, - 1, 1, - 1, 1, + 1, 1, - 1, 1, 1, 1, + + // (0, ±1/φ, ±φ) + 0, - r, - t, 0, - r, t, + 0, r, - t, 0, r, t, + + // (±1/φ, ±φ, 0) + - r, - t, 0, - r, t, 0, + r, - t, 0, r, t, 0, + + // (±φ, 0, ±1/φ) + - t, 0, - r, t, 0, - r, + - t, 0, r, t, 0, r + ]; + + var indices = [ + 3, 11, 7, 3, 7, 15, 3, 15, 13, + 7, 19, 17, 7, 17, 6, 7, 6, 15, + 17, 4, 8, 17, 8, 10, 17, 10, 6, + 8, 0, 16, 8, 16, 2, 8, 2, 10, + 0, 12, 1, 0, 1, 18, 0, 18, 16, + 6, 10, 2, 6, 2, 13, 6, 13, 15, + 2, 16, 18, 2, 18, 3, 2, 3, 13, + 18, 1, 9, 18, 9, 11, 18, 11, 3, + 4, 14, 12, 4, 12, 0, 4, 0, 8, + 11, 9, 5, 11, 5, 19, 11, 19, 7, + 19, 5, 14, 19, 14, 4, 19, 4, 17, + 1, 12, 14, 1, 14, 5, 1, 5, 9 + ]; + + PolyhedronBufferGeometry.call( this, vertices, indices, radius, detail ); + + this.type = 'DodecahedronBufferGeometry'; + + this.parameters = { + radius: radius, + detail: detail + }; + + } + + DodecahedronBufferGeometry.prototype = Object.create( PolyhedronBufferGeometry.prototype ); + DodecahedronBufferGeometry.prototype.constructor = DodecahedronBufferGeometry; + + /** + * @author oosmoxiecode / https://github.com/oosmoxiecode + * @author WestLangley / https://github.com/WestLangley + * @author zz85 / https://github.com/zz85 + * @author miningold / https://github.com/miningold + * @author jonobr1 / https://github.com/jonobr1 + * @author Mugen87 / https://github.com/Mugen87 + * + */ + + // TubeGeometry + + function TubeGeometry( path, tubularSegments, radius, radialSegments, closed, taper ) { + + Geometry.call( this ); + + this.type = 'TubeGeometry'; + + this.parameters = { + path: path, + tubularSegments: tubularSegments, + radius: radius, + radialSegments: radialSegments, + closed: closed + }; + + if ( taper !== undefined ) console.warn( 'THREE.TubeGeometry: taper has been removed.' ); + + var bufferGeometry = new TubeBufferGeometry( path, tubularSegments, radius, radialSegments, closed ); + + // expose internals + + this.tangents = bufferGeometry.tangents; + this.normals = bufferGeometry.normals; + this.binormals = bufferGeometry.binormals; + + // create geometry + + this.fromBufferGeometry( bufferGeometry ); + this.mergeVertices(); + + } + + TubeGeometry.prototype = Object.create( Geometry.prototype ); + TubeGeometry.prototype.constructor = TubeGeometry; + + // TubeBufferGeometry + + function TubeBufferGeometry( path, tubularSegments, radius, radialSegments, closed ) { + + BufferGeometry.call( this ); + + this.type = 'TubeBufferGeometry'; + + this.parameters = { + path: path, + tubularSegments: tubularSegments, + radius: radius, + radialSegments: radialSegments, + closed: closed + }; + + tubularSegments = tubularSegments || 64; + radius = radius || 1; + radialSegments = radialSegments || 8; + closed = closed || false; + + var frames = path.computeFrenetFrames( tubularSegments, closed ); + + // expose internals + + this.tangents = frames.tangents; + this.normals = frames.normals; + this.binormals = frames.binormals; + + // helper variables + + var vertex = new Vector3(); + var normal = new Vector3(); + var uv = new Vector2(); + var P = new Vector3(); + + var i, j; + + // buffer + + var vertices = []; + var normals = []; + var uvs = []; + var indices = []; + + // create buffer data + + generateBufferData(); + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + // functions + + function generateBufferData() { + + for ( i = 0; i < tubularSegments; i ++ ) { + + generateSegment( i ); + + } + + // if the geometry is not closed, generate the last row of vertices and normals + // at the regular position on the given path + // + // if the geometry is closed, duplicate the first row of vertices and normals (uvs will differ) + + generateSegment( ( closed === false ) ? tubularSegments : 0 ); + + // uvs are generated in a separate function. + // this makes it easy compute correct values for closed geometries + + generateUVs(); + + // finally create faces + + generateIndices(); + + } + + function generateSegment( i ) { + + // we use getPointAt to sample evenly distributed points from the given path + + P = path.getPointAt( i / tubularSegments, P ); + + // retrieve corresponding normal and binormal + + var N = frames.normals[ i ]; + var B = frames.binormals[ i ]; + + // generate normals and vertices for the current segment + + for ( j = 0; j <= radialSegments; j ++ ) { + + var v = j / radialSegments * Math.PI * 2; + + var sin = Math.sin( v ); + var cos = - Math.cos( v ); + + // normal + + normal.x = ( cos * N.x + sin * B.x ); + normal.y = ( cos * N.y + sin * B.y ); + normal.z = ( cos * N.z + sin * B.z ); + normal.normalize(); + + normals.push( normal.x, normal.y, normal.z ); + + // vertex + + vertex.x = P.x + radius * normal.x; + vertex.y = P.y + radius * normal.y; + vertex.z = P.z + radius * normal.z; + + vertices.push( vertex.x, vertex.y, vertex.z ); + + } + + } + + function generateIndices() { + + for ( j = 1; j <= tubularSegments; j ++ ) { + + for ( i = 1; i <= radialSegments; i ++ ) { + + var a = ( radialSegments + 1 ) * ( j - 1 ) + ( i - 1 ); + var b = ( radialSegments + 1 ) * j + ( i - 1 ); + var c = ( radialSegments + 1 ) * j + i; + var d = ( radialSegments + 1 ) * ( j - 1 ) + i; + + // faces + + indices.push( a, b, d ); + indices.push( b, c, d ); + + } + + } + + } + + function generateUVs() { + + for ( i = 0; i <= tubularSegments; i ++ ) { + + for ( j = 0; j <= radialSegments; j ++ ) { + + uv.x = i / tubularSegments; + uv.y = j / radialSegments; + + uvs.push( uv.x, uv.y ); + + } + + } + + } + + } + + TubeBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + TubeBufferGeometry.prototype.constructor = TubeBufferGeometry; + + /** + * @author oosmoxiecode + * @author Mugen87 / https://github.com/Mugen87 + * + * based on http://www.blackpawn.com/texts/pqtorus/ + */ + + // TorusKnotGeometry + + function TorusKnotGeometry( radius, tube, tubularSegments, radialSegments, p, q, heightScale ) { + + Geometry.call( this ); + + this.type = 'TorusKnotGeometry'; + + this.parameters = { + radius: radius, + tube: tube, + tubularSegments: tubularSegments, + radialSegments: radialSegments, + p: p, + q: q + }; + + if ( heightScale !== undefined ) console.warn( 'THREE.TorusKnotGeometry: heightScale has been deprecated. Use .scale( x, y, z ) instead.' ); + + this.fromBufferGeometry( new TorusKnotBufferGeometry( radius, tube, tubularSegments, radialSegments, p, q ) ); + this.mergeVertices(); + + } + + TorusKnotGeometry.prototype = Object.create( Geometry.prototype ); + TorusKnotGeometry.prototype.constructor = TorusKnotGeometry; + + // TorusKnotBufferGeometry + + function TorusKnotBufferGeometry( radius, tube, tubularSegments, radialSegments, p, q ) { + + BufferGeometry.call( this ); + + this.type = 'TorusKnotBufferGeometry'; + + this.parameters = { + radius: radius, + tube: tube, + tubularSegments: tubularSegments, + radialSegments: radialSegments, + p: p, + q: q + }; + + radius = radius || 1; + tube = tube || 0.4; + tubularSegments = Math.floor( tubularSegments ) || 64; + radialSegments = Math.floor( radialSegments ) || 8; + p = p || 2; + q = q || 3; + + // buffers + + var indices = []; + var vertices = []; + var normals = []; + var uvs = []; + + // helper variables + + var i, j; + + var vertex = new Vector3(); + var normal = new Vector3(); + + var P1 = new Vector3(); + var P2 = new Vector3(); + + var B = new Vector3(); + var T = new Vector3(); + var N = new Vector3(); + + // generate vertices, normals and uvs + + for ( i = 0; i <= tubularSegments; ++ i ) { + + // the radian "u" is used to calculate the position on the torus curve of the current tubular segement + + var u = i / tubularSegments * p * Math.PI * 2; + + // now we calculate two points. P1 is our current position on the curve, P2 is a little farther ahead. + // these points are used to create a special "coordinate space", which is necessary to calculate the correct vertex positions + + calculatePositionOnCurve( u, p, q, radius, P1 ); + calculatePositionOnCurve( u + 0.01, p, q, radius, P2 ); + + // calculate orthonormal basis + + T.subVectors( P2, P1 ); + N.addVectors( P2, P1 ); + B.crossVectors( T, N ); + N.crossVectors( B, T ); + + // normalize B, N. T can be ignored, we don't use it + + B.normalize(); + N.normalize(); + + for ( j = 0; j <= radialSegments; ++ j ) { + + // now calculate the vertices. they are nothing more than an extrusion of the torus curve. + // because we extrude a shape in the xy-plane, there is no need to calculate a z-value. + + var v = j / radialSegments * Math.PI * 2; + var cx = - tube * Math.cos( v ); + var cy = tube * Math.sin( v ); + + // now calculate the final vertex position. + // first we orient the extrusion with our basis vectos, then we add it to the current position on the curve + + vertex.x = P1.x + ( cx * N.x + cy * B.x ); + vertex.y = P1.y + ( cx * N.y + cy * B.y ); + vertex.z = P1.z + ( cx * N.z + cy * B.z ); + + vertices.push( vertex.x, vertex.y, vertex.z ); + + // normal (P1 is always the center/origin of the extrusion, thus we can use it to calculate the normal) + + normal.subVectors( vertex, P1 ).normalize(); + + normals.push( normal.x, normal.y, normal.z ); + + // uv + + uvs.push( i / tubularSegments ); + uvs.push( j / radialSegments ); + + } + + } + + // generate indices + + for ( j = 1; j <= tubularSegments; j ++ ) { + + for ( i = 1; i <= radialSegments; i ++ ) { + + // indices + + var a = ( radialSegments + 1 ) * ( j - 1 ) + ( i - 1 ); + var b = ( radialSegments + 1 ) * j + ( i - 1 ); + var c = ( radialSegments + 1 ) * j + i; + var d = ( radialSegments + 1 ) * ( j - 1 ) + i; + + // faces + + indices.push( a, b, d ); + indices.push( b, c, d ); + + } + + } + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + // this function calculates the current position on the torus curve + + function calculatePositionOnCurve( u, p, q, radius, position ) { + + var cu = Math.cos( u ); + var su = Math.sin( u ); + var quOverP = q / p * u; + var cs = Math.cos( quOverP ); + + position.x = radius * ( 2 + cs ) * 0.5 * cu; + position.y = radius * ( 2 + cs ) * su * 0.5; + position.z = radius * Math.sin( quOverP ) * 0.5; + + } + + } + + TorusKnotBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + TorusKnotBufferGeometry.prototype.constructor = TorusKnotBufferGeometry; + + /** + * @author oosmoxiecode + * @author mrdoob / http://mrdoob.com/ + * @author Mugen87 / https://github.com/Mugen87 + */ + + // TorusGeometry + + function TorusGeometry( radius, tube, radialSegments, tubularSegments, arc ) { + + Geometry.call( this ); + + this.type = 'TorusGeometry'; + + this.parameters = { + radius: radius, + tube: tube, + radialSegments: radialSegments, + tubularSegments: tubularSegments, + arc: arc + }; + + this.fromBufferGeometry( new TorusBufferGeometry( radius, tube, radialSegments, tubularSegments, arc ) ); + this.mergeVertices(); + + } + + TorusGeometry.prototype = Object.create( Geometry.prototype ); + TorusGeometry.prototype.constructor = TorusGeometry; + + // TorusBufferGeometry + + function TorusBufferGeometry( radius, tube, radialSegments, tubularSegments, arc ) { + + BufferGeometry.call( this ); + + this.type = 'TorusBufferGeometry'; + + this.parameters = { + radius: radius, + tube: tube, + radialSegments: radialSegments, + tubularSegments: tubularSegments, + arc: arc + }; + + radius = radius || 1; + tube = tube || 0.4; + radialSegments = Math.floor( radialSegments ) || 8; + tubularSegments = Math.floor( tubularSegments ) || 6; + arc = arc || Math.PI * 2; + + // buffers + + var indices = []; + var vertices = []; + var normals = []; + var uvs = []; + + // helper variables + + var center = new Vector3(); + var vertex = new Vector3(); + var normal = new Vector3(); + + var j, i; + + // generate vertices, normals and uvs + + for ( j = 0; j <= radialSegments; j ++ ) { + + for ( i = 0; i <= tubularSegments; i ++ ) { + + var u = i / tubularSegments * arc; + var v = j / radialSegments * Math.PI * 2; + + // vertex + + vertex.x = ( radius + tube * Math.cos( v ) ) * Math.cos( u ); + vertex.y = ( radius + tube * Math.cos( v ) ) * Math.sin( u ); + vertex.z = tube * Math.sin( v ); + + vertices.push( vertex.x, vertex.y, vertex.z ); + + // normal + + center.x = radius * Math.cos( u ); + center.y = radius * Math.sin( u ); + normal.subVectors( vertex, center ).normalize(); + + normals.push( normal.x, normal.y, normal.z ); + + // uv + + uvs.push( i / tubularSegments ); + uvs.push( j / radialSegments ); + + } + + } + + // generate indices + + for ( j = 1; j <= radialSegments; j ++ ) { + + for ( i = 1; i <= tubularSegments; i ++ ) { + + // indices + + var a = ( tubularSegments + 1 ) * j + i - 1; + var b = ( tubularSegments + 1 ) * ( j - 1 ) + i - 1; + var c = ( tubularSegments + 1 ) * ( j - 1 ) + i; + var d = ( tubularSegments + 1 ) * j + i; + + // faces + + indices.push( a, b, d ); + indices.push( b, c, d ); + + } + + } + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + } + + TorusBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + TorusBufferGeometry.prototype.constructor = TorusBufferGeometry; + + /** + * @author Mugen87 / https://github.com/Mugen87 + * Port from https://github.com/mapbox/earcut (v2.1.2) + */ + + var Earcut = { + + triangulate: function ( data, holeIndices, dim ) { + + dim = dim || 2; + + var hasHoles = holeIndices && holeIndices.length, + outerLen = hasHoles ? holeIndices[ 0 ] * dim : data.length, + outerNode = linkedList( data, 0, outerLen, dim, true ), + triangles = []; + + if ( ! outerNode ) return triangles; + + var minX, minY, maxX, maxY, x, y, invSize; + + if ( hasHoles ) outerNode = eliminateHoles( data, holeIndices, outerNode, dim ); + + // if the shape is not too simple, we'll use z-order curve hash later; calculate polygon bbox + + if ( data.length > 80 * dim ) { + + minX = maxX = data[ 0 ]; + minY = maxY = data[ 1 ]; + + for ( var i = dim; i < outerLen; i += dim ) { + + x = data[ i ]; + y = data[ i + 1 ]; + if ( x < minX ) minX = x; + if ( y < minY ) minY = y; + if ( x > maxX ) maxX = x; + if ( y > maxY ) maxY = y; + + } + + // minX, minY and invSize are later used to transform coords into integers for z-order calculation + + invSize = Math.max( maxX - minX, maxY - minY ); + invSize = invSize !== 0 ? 1 / invSize : 0; + + } + + earcutLinked( outerNode, triangles, dim, minX, minY, invSize ); + + return triangles; + + } + + }; + + // create a circular doubly linked list from polygon points in the specified winding order + + function linkedList( data, start, end, dim, clockwise ) { + + var i, last; + + if ( clockwise === ( signedArea( data, start, end, dim ) > 0 ) ) { + + for ( i = start; i < end; i += dim ) last = insertNode( i, data[ i ], data[ i + 1 ], last ); + + } else { + + for ( i = end - dim; i >= start; i -= dim ) last = insertNode( i, data[ i ], data[ i + 1 ], last ); + + } + + if ( last && equals( last, last.next ) ) { + + removeNode( last ); + last = last.next; + + } + + return last; + + } + + // eliminate colinear or duplicate points + + function filterPoints( start, end ) { + + if ( ! start ) return start; + if ( ! end ) end = start; + + var p = start, again; + + do { + + again = false; + + if ( ! p.steiner && ( equals( p, p.next ) || area( p.prev, p, p.next ) === 0 ) ) { + + removeNode( p ); + p = end = p.prev; + if ( p === p.next ) break; + again = true; + + } else { + + p = p.next; + + } + + } while ( again || p !== end ); + + return end; + + } + + // main ear slicing loop which triangulates a polygon (given as a linked list) + + function earcutLinked( ear, triangles, dim, minX, minY, invSize, pass ) { + + if ( ! ear ) return; + + // interlink polygon nodes in z-order + + if ( ! pass && invSize ) indexCurve( ear, minX, minY, invSize ); + + var stop = ear, prev, next; + + // iterate through ears, slicing them one by one + + while ( ear.prev !== ear.next ) { + + prev = ear.prev; + next = ear.next; + + if ( invSize ? isEarHashed( ear, minX, minY, invSize ) : isEar( ear ) ) { + + // cut off the triangle + triangles.push( prev.i / dim ); + triangles.push( ear.i / dim ); + triangles.push( next.i / dim ); + + removeNode( ear ); + + // skipping the next vertice leads to less sliver triangles + ear = next.next; + stop = next.next; + + continue; + + } + + ear = next; + + // if we looped through the whole remaining polygon and can't find any more ears + + if ( ear === stop ) { + + // try filtering points and slicing again + + if ( ! pass ) { + + earcutLinked( filterPoints( ear ), triangles, dim, minX, minY, invSize, 1 ); + + // if this didn't work, try curing all small self-intersections locally + + } else if ( pass === 1 ) { + + ear = cureLocalIntersections( ear, triangles, dim ); + earcutLinked( ear, triangles, dim, minX, minY, invSize, 2 ); + + // as a last resort, try splitting the remaining polygon into two + + } else if ( pass === 2 ) { + + splitEarcut( ear, triangles, dim, minX, minY, invSize ); + + } + + break; + + } + + } + + } + + // check whether a polygon node forms a valid ear with adjacent nodes + + function isEar( ear ) { + + var a = ear.prev, + b = ear, + c = ear.next; + + if ( area( a, b, c ) >= 0 ) return false; // reflex, can't be an ear + + // now make sure we don't have other points inside the potential ear + var p = ear.next.next; + + while ( p !== ear.prev ) { + + if ( pointInTriangle( a.x, a.y, b.x, b.y, c.x, c.y, p.x, p.y ) && area( p.prev, p, p.next ) >= 0 ) { + + return false; + + } + + p = p.next; + + } + + return true; + + } + + function isEarHashed( ear, minX, minY, invSize ) { + + var a = ear.prev, + b = ear, + c = ear.next; + + if ( area( a, b, c ) >= 0 ) return false; // reflex, can't be an ear + + // triangle bbox; min & max are calculated like this for speed + + var minTX = a.x < b.x ? ( a.x < c.x ? a.x : c.x ) : ( b.x < c.x ? b.x : c.x ), + minTY = a.y < b.y ? ( a.y < c.y ? a.y : c.y ) : ( b.y < c.y ? b.y : c.y ), + maxTX = a.x > b.x ? ( a.x > c.x ? a.x : c.x ) : ( b.x > c.x ? b.x : c.x ), + maxTY = a.y > b.y ? ( a.y > c.y ? a.y : c.y ) : ( b.y > c.y ? b.y : c.y ); + + // z-order range for the current triangle bbox; + + var minZ = zOrder( minTX, minTY, minX, minY, invSize ), + maxZ = zOrder( maxTX, maxTY, minX, minY, invSize ); + + // first look for points inside the triangle in increasing z-order + + var p = ear.nextZ; + + while ( p && p.z <= maxZ ) { + + if ( p !== ear.prev && p !== ear.next && + pointInTriangle( a.x, a.y, b.x, b.y, c.x, c.y, p.x, p.y ) && + area( p.prev, p, p.next ) >= 0 ) return false; + p = p.nextZ; + + } + + // then look for points in decreasing z-order + + p = ear.prevZ; + + while ( p && p.z >= minZ ) { + + if ( p !== ear.prev && p !== ear.next && + pointInTriangle( a.x, a.y, b.x, b.y, c.x, c.y, p.x, p.y ) && + area( p.prev, p, p.next ) >= 0 ) return false; + + p = p.prevZ; + + } + + return true; + + } + + // go through all polygon nodes and cure small local self-intersections + + function cureLocalIntersections( start, triangles, dim ) { + + var p = start; + + do { + + var a = p.prev, b = p.next.next; + + if ( ! equals( a, b ) && intersects( a, p, p.next, b ) && locallyInside( a, b ) && locallyInside( b, a ) ) { + + triangles.push( a.i / dim ); + triangles.push( p.i / dim ); + triangles.push( b.i / dim ); + + // remove two nodes involved + + removeNode( p ); + removeNode( p.next ); + + p = start = b; + + } + + p = p.next; + + } while ( p !== start ); + + return p; + + } + + // try splitting polygon into two and triangulate them independently + + function splitEarcut( start, triangles, dim, minX, minY, invSize ) { + + // look for a valid diagonal that divides the polygon into two + + var a = start; + + do { + + var b = a.next.next; + + while ( b !== a.prev ) { + + if ( a.i !== b.i && isValidDiagonal( a, b ) ) { + + // split the polygon in two by the diagonal + + var c = splitPolygon( a, b ); + + // filter colinear points around the cuts + + a = filterPoints( a, a.next ); + c = filterPoints( c, c.next ); + + // run earcut on each half + + earcutLinked( a, triangles, dim, minX, minY, invSize ); + earcutLinked( c, triangles, dim, minX, minY, invSize ); + return; + + } + + b = b.next; + + } + + a = a.next; + + } while ( a !== start ); + + } + + // link every hole into the outer loop, producing a single-ring polygon without holes + + function eliminateHoles( data, holeIndices, outerNode, dim ) { + + var queue = [], i, len, start, end, list; + + for ( i = 0, len = holeIndices.length; i < len; i ++ ) { + + start = holeIndices[ i ] * dim; + end = i < len - 1 ? holeIndices[ i + 1 ] * dim : data.length; + list = linkedList( data, start, end, dim, false ); + if ( list === list.next ) list.steiner = true; + queue.push( getLeftmost( list ) ); + + } + + queue.sort( compareX ); + + // process holes from left to right + + for ( i = 0; i < queue.length; i ++ ) { + + eliminateHole( queue[ i ], outerNode ); + outerNode = filterPoints( outerNode, outerNode.next ); + + } + + return outerNode; + + } + + function compareX( a, b ) { + + return a.x - b.x; + + } + + // find a bridge between vertices that connects hole with an outer ring and and link it + + function eliminateHole( hole, outerNode ) { + + outerNode = findHoleBridge( hole, outerNode ); + + if ( outerNode ) { + + var b = splitPolygon( outerNode, hole ); + + filterPoints( b, b.next ); + + } + + } + + // David Eberly's algorithm for finding a bridge between hole and outer polygon + + function findHoleBridge( hole, outerNode ) { + + var p = outerNode, + hx = hole.x, + hy = hole.y, + qx = - Infinity, + m; + + // find a segment intersected by a ray from the hole's leftmost point to the left; + // segment's endpoint with lesser x will be potential connection point + + do { + + if ( hy <= p.y && hy >= p.next.y && p.next.y !== p.y ) { + + var x = p.x + ( hy - p.y ) * ( p.next.x - p.x ) / ( p.next.y - p.y ); + + if ( x <= hx && x > qx ) { + + qx = x; + + if ( x === hx ) { + + if ( hy === p.y ) return p; + if ( hy === p.next.y ) return p.next; + + } + + m = p.x < p.next.x ? p : p.next; + + } + + } + + p = p.next; + + } while ( p !== outerNode ); + + if ( ! m ) return null; + + if ( hx === qx ) return m.prev; // hole touches outer segment; pick lower endpoint + + // look for points inside the triangle of hole point, segment intersection and endpoint; + // if there are no points found, we have a valid connection; + // otherwise choose the point of the minimum angle with the ray as connection point + + var stop = m, + mx = m.x, + my = m.y, + tanMin = Infinity, + tan; + + p = m.next; + + while ( p !== stop ) { + + if ( hx >= p.x && p.x >= mx && hx !== p.x && + pointInTriangle( hy < my ? hx : qx, hy, mx, my, hy < my ? qx : hx, hy, p.x, p.y ) ) { + + tan = Math.abs( hy - p.y ) / ( hx - p.x ); // tangential + + if ( ( tan < tanMin || ( tan === tanMin && p.x > m.x ) ) && locallyInside( p, hole ) ) { + + m = p; + tanMin = tan; + + } + + } + + p = p.next; + + } + + return m; + + } + + // interlink polygon nodes in z-order + + function indexCurve( start, minX, minY, invSize ) { + + var p = start; + + do { + + if ( p.z === null ) p.z = zOrder( p.x, p.y, minX, minY, invSize ); + p.prevZ = p.prev; + p.nextZ = p.next; + p = p.next; + + } while ( p !== start ); + + p.prevZ.nextZ = null; + p.prevZ = null; + + sortLinked( p ); + + } + + // Simon Tatham's linked list merge sort algorithm + // http://www.chiark.greenend.org.uk/~sgtatham/algorithms/listsort.html + + function sortLinked( list ) { + + var i, p, q, e, tail, numMerges, pSize, qSize, inSize = 1; + + do { + + p = list; + list = null; + tail = null; + numMerges = 0; + + while ( p ) { + + numMerges ++; + q = p; + pSize = 0; + + for ( i = 0; i < inSize; i ++ ) { + + pSize ++; + q = q.nextZ; + if ( ! q ) break; + + } + + qSize = inSize; + + while ( pSize > 0 || ( qSize > 0 && q ) ) { + + if ( pSize !== 0 && ( qSize === 0 || ! q || p.z <= q.z ) ) { + + e = p; + p = p.nextZ; + pSize --; + + } else { + + e = q; + q = q.nextZ; + qSize --; + + } + + if ( tail ) tail.nextZ = e; + else list = e; + + e.prevZ = tail; + tail = e; + + } + + p = q; + + } + + tail.nextZ = null; + inSize *= 2; + + } while ( numMerges > 1 ); + + return list; + + } + + // z-order of a point given coords and inverse of the longer side of data bbox + + function zOrder( x, y, minX, minY, invSize ) { + + // coords are transformed into non-negative 15-bit integer range + + x = 32767 * ( x - minX ) * invSize; + y = 32767 * ( y - minY ) * invSize; + + x = ( x | ( x << 8 ) ) & 0x00FF00FF; + x = ( x | ( x << 4 ) ) & 0x0F0F0F0F; + x = ( x | ( x << 2 ) ) & 0x33333333; + x = ( x | ( x << 1 ) ) & 0x55555555; + + y = ( y | ( y << 8 ) ) & 0x00FF00FF; + y = ( y | ( y << 4 ) ) & 0x0F0F0F0F; + y = ( y | ( y << 2 ) ) & 0x33333333; + y = ( y | ( y << 1 ) ) & 0x55555555; + + return x | ( y << 1 ); + + } + + // find the leftmost node of a polygon ring + + function getLeftmost( start ) { + + var p = start, leftmost = start; + + do { + + if ( p.x < leftmost.x ) leftmost = p; + p = p.next; + + } while ( p !== start ); + + return leftmost; + + } + + // check if a point lies within a convex triangle + + function pointInTriangle( ax, ay, bx, by, cx, cy, px, py ) { + + return ( cx - px ) * ( ay - py ) - ( ax - px ) * ( cy - py ) >= 0 && + ( ax - px ) * ( by - py ) - ( bx - px ) * ( ay - py ) >= 0 && + ( bx - px ) * ( cy - py ) - ( cx - px ) * ( by - py ) >= 0; + + } + + // check if a diagonal between two polygon nodes is valid (lies in polygon interior) + + function isValidDiagonal( a, b ) { + + return a.next.i !== b.i && a.prev.i !== b.i && ! intersectsPolygon( a, b ) && + locallyInside( a, b ) && locallyInside( b, a ) && middleInside( a, b ); + + } + + // signed area of a triangle + + function area( p, q, r ) { + + return ( q.y - p.y ) * ( r.x - q.x ) - ( q.x - p.x ) * ( r.y - q.y ); + + } + + // check if two points are equal + + function equals( p1, p2 ) { + + return p1.x === p2.x && p1.y === p2.y; + + } + + // check if two segments intersect + + function intersects( p1, q1, p2, q2 ) { + + if ( ( equals( p1, q1 ) && equals( p2, q2 ) ) || + ( equals( p1, q2 ) && equals( p2, q1 ) ) ) return true; + + return area( p1, q1, p2 ) > 0 !== area( p1, q1, q2 ) > 0 && + area( p2, q2, p1 ) > 0 !== area( p2, q2, q1 ) > 0; + + } + + // check if a polygon diagonal intersects any polygon segments + + function intersectsPolygon( a, b ) { + + var p = a; + + do { + + if ( p.i !== a.i && p.next.i !== a.i && p.i !== b.i && p.next.i !== b.i && + intersects( p, p.next, a, b ) ) { + + return true; + + } + + p = p.next; + + } while ( p !== a ); + + return false; + + } + + // check if a polygon diagonal is locally inside the polygon + + function locallyInside( a, b ) { + + return area( a.prev, a, a.next ) < 0 ? + area( a, b, a.next ) >= 0 && area( a, a.prev, b ) >= 0 : + area( a, b, a.prev ) < 0 || area( a, a.next, b ) < 0; + + } + + // check if the middle point of a polygon diagonal is inside the polygon + + function middleInside( a, b ) { + + var p = a, + inside = false, + px = ( a.x + b.x ) / 2, + py = ( a.y + b.y ) / 2; + + do { + + if ( ( ( p.y > py ) !== ( p.next.y > py ) ) && p.next.y !== p.y && + ( px < ( p.next.x - p.x ) * ( py - p.y ) / ( p.next.y - p.y ) + p.x ) ) { + + inside = ! inside; + + } + + p = p.next; + + } while ( p !== a ); + + return inside; + + } + + // link two polygon vertices with a bridge; if the vertices belong to the same ring, it splits polygon into two; + // if one belongs to the outer ring and another to a hole, it merges it into a single ring + + function splitPolygon( a, b ) { + + var a2 = new Node( a.i, a.x, a.y ), + b2 = new Node( b.i, b.x, b.y ), + an = a.next, + bp = b.prev; + + a.next = b; + b.prev = a; + + a2.next = an; + an.prev = a2; + + b2.next = a2; + a2.prev = b2; + + bp.next = b2; + b2.prev = bp; + + return b2; + + } + + // create a node and optionally link it with previous one (in a circular doubly linked list) + + function insertNode( i, x, y, last ) { + + var p = new Node( i, x, y ); + + if ( ! last ) { + + p.prev = p; + p.next = p; + + } else { + + p.next = last.next; + p.prev = last; + last.next.prev = p; + last.next = p; + + } + + return p; + + } + + function removeNode( p ) { + + p.next.prev = p.prev; + p.prev.next = p.next; + + if ( p.prevZ ) p.prevZ.nextZ = p.nextZ; + if ( p.nextZ ) p.nextZ.prevZ = p.prevZ; + + } + + function Node( i, x, y ) { + + // vertice index in coordinates array + this.i = i; + + // vertex coordinates + this.x = x; + this.y = y; + + // previous and next vertice nodes in a polygon ring + this.prev = null; + this.next = null; + + // z-order curve value + this.z = null; + + // previous and next nodes in z-order + this.prevZ = null; + this.nextZ = null; + + // indicates whether this is a steiner point + this.steiner = false; + + } + + function signedArea( data, start, end, dim ) { + + var sum = 0; + + for ( var i = start, j = end - dim; i < end; i += dim ) { + + sum += ( data[ j ] - data[ i ] ) * ( data[ i + 1 ] + data[ j + 1 ] ); + j = i; + + } + + return sum; + + } + + /** + * @author zz85 / http://www.lab4games.net/zz85/blog + */ + + var ShapeUtils = { + + // calculate area of the contour polygon + + area: function ( contour ) { + + var n = contour.length; + var a = 0.0; + + for ( var p = n - 1, q = 0; q < n; p = q ++ ) { + + a += contour[ p ].x * contour[ q ].y - contour[ q ].x * contour[ p ].y; + + } + + return a * 0.5; + + }, + + isClockWise: function ( pts ) { + + return ShapeUtils.area( pts ) < 0; + + }, + + triangulateShape: function ( contour, holes ) { + + var vertices = []; // flat array of vertices like [ x0,y0, x1,y1, x2,y2, ... ] + var holeIndices = []; // array of hole indices + var faces = []; // final array of vertex indices like [ [ a,b,d ], [ b,c,d ] ] + + removeDupEndPts( contour ); + addContour( vertices, contour ); + + // + + var holeIndex = contour.length; + + holes.forEach( removeDupEndPts ); + + for ( var i = 0; i < holes.length; i ++ ) { + + holeIndices.push( holeIndex ); + holeIndex += holes[ i ].length; + addContour( vertices, holes[ i ] ); + + } + + // + + var triangles = Earcut.triangulate( vertices, holeIndices ); + + // + + for ( var i = 0; i < triangles.length; i += 3 ) { + + faces.push( triangles.slice( i, i + 3 ) ); + + } + + return faces; + + } + + }; + + function removeDupEndPts( points ) { + + var l = points.length; + + if ( l > 2 && points[ l - 1 ].equals( points[ 0 ] ) ) { + + points.pop(); + + } + + } + + function addContour( vertices, contour ) { + + for ( var i = 0; i < contour.length; i ++ ) { + + vertices.push( contour[ i ].x ); + vertices.push( contour[ i ].y ); + + } + + } + + /** + * @author zz85 / http://www.lab4games.net/zz85/blog + * + * Creates extruded geometry from a path shape. + * + * parameters = { + * + * curveSegments: , // number of points on the curves + * steps: , // number of points for z-side extrusions / used for subdividing segments of extrude spline too + * depth: , // Depth to extrude the shape + * + * bevelEnabled: , // turn on bevel + * bevelThickness: , // how deep into the original shape bevel goes + * bevelSize: , // how far from shape outline is bevel + * bevelSegments: , // number of bevel layers + * + * extrudePath: // curve to extrude shape along + * + * UVGenerator: // object that provides UV generator functions + * + * } + */ + + // ExtrudeGeometry + + function ExtrudeGeometry( shapes, options ) { + + Geometry.call( this ); + + this.type = 'ExtrudeGeometry'; + + this.parameters = { + shapes: shapes, + options: options + }; + + this.fromBufferGeometry( new ExtrudeBufferGeometry( shapes, options ) ); + this.mergeVertices(); + + } + + ExtrudeGeometry.prototype = Object.create( Geometry.prototype ); + ExtrudeGeometry.prototype.constructor = ExtrudeGeometry; + + ExtrudeGeometry.prototype.toJSON = function () { + + var data = Geometry.prototype.toJSON.call( this ); + + var shapes = this.parameters.shapes; + var options = this.parameters.options; + + return toJSON( shapes, options, data ); + + }; + + // ExtrudeBufferGeometry + + function ExtrudeBufferGeometry( shapes, options ) { + + BufferGeometry.call( this ); + + this.type = 'ExtrudeBufferGeometry'; + + this.parameters = { + shapes: shapes, + options: options + }; + + shapes = Array.isArray( shapes ) ? shapes : [ shapes ]; + + var scope = this; + + var verticesArray = []; + var uvArray = []; + + for ( var i = 0, l = shapes.length; i < l; i ++ ) { + + var shape = shapes[ i ]; + addShape( shape ); + + } + + // build geometry + + this.addAttribute( 'position', new Float32BufferAttribute( verticesArray, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvArray, 2 ) ); + + this.computeVertexNormals(); + + // functions + + function addShape( shape ) { + + var placeholder = []; + + // options + + var curveSegments = options.curveSegments !== undefined ? options.curveSegments : 12; + var steps = options.steps !== undefined ? options.steps : 1; + var depth = options.depth !== undefined ? options.depth : 100; + + var bevelEnabled = options.bevelEnabled !== undefined ? options.bevelEnabled : true; + var bevelThickness = options.bevelThickness !== undefined ? options.bevelThickness : 6; + var bevelSize = options.bevelSize !== undefined ? options.bevelSize : bevelThickness - 2; + var bevelSegments = options.bevelSegments !== undefined ? options.bevelSegments : 3; + + var extrudePath = options.extrudePath; + + var uvgen = options.UVGenerator !== undefined ? options.UVGenerator : WorldUVGenerator; + + // deprecated options + + if ( options.amount !== undefined ) { + + console.warn( 'THREE.ExtrudeBufferGeometry: amount has been renamed to depth.' ); + depth = options.amount; + + } + + // + + var extrudePts, extrudeByPath = false; + var splineTube, binormal, normal, position2; + + if ( extrudePath ) { + + extrudePts = extrudePath.getSpacedPoints( steps ); + + extrudeByPath = true; + bevelEnabled = false; // bevels not supported for path extrusion + + // SETUP TNB variables + + // TODO1 - have a .isClosed in spline? + + splineTube = extrudePath.computeFrenetFrames( steps, false ); + + // console.log(splineTube, 'splineTube', splineTube.normals.length, 'steps', steps, 'extrudePts', extrudePts.length); + + binormal = new Vector3(); + normal = new Vector3(); + position2 = new Vector3(); + + } + + // Safeguards if bevels are not enabled + + if ( ! bevelEnabled ) { + + bevelSegments = 0; + bevelThickness = 0; + bevelSize = 0; + + } + + // Variables initialization + + var ahole, h, hl; // looping of holes + + var shapePoints = shape.extractPoints( curveSegments ); + + var vertices = shapePoints.shape; + var holes = shapePoints.holes; + + var reverse = ! ShapeUtils.isClockWise( vertices ); + + if ( reverse ) { + + vertices = vertices.reverse(); + + // Maybe we should also check if holes are in the opposite direction, just to be safe ... + + for ( h = 0, hl = holes.length; h < hl; h ++ ) { + + ahole = holes[ h ]; + + if ( ShapeUtils.isClockWise( ahole ) ) { + + holes[ h ] = ahole.reverse(); + + } + + } + + } + + + var faces = ShapeUtils.triangulateShape( vertices, holes ); + + /* Vertices */ + + var contour = vertices; // vertices has all points but contour has only points of circumference + + for ( h = 0, hl = holes.length; h < hl; h ++ ) { + + ahole = holes[ h ]; + + vertices = vertices.concat( ahole ); + + } + + + function scalePt2( pt, vec, size ) { + + if ( ! vec ) console.error( "THREE.ExtrudeGeometry: vec does not exist" ); + + return vec.clone().multiplyScalar( size ).add( pt ); + + } + + var b, bs, t, z, + vert, vlen = vertices.length, + face, flen = faces.length; + + + // Find directions for point movement + + + function getBevelVec( inPt, inPrev, inNext ) { + + // computes for inPt the corresponding point inPt' on a new contour + // shifted by 1 unit (length of normalized vector) to the left + // if we walk along contour clockwise, this new contour is outside the old one + // + // inPt' is the intersection of the two lines parallel to the two + // adjacent edges of inPt at a distance of 1 unit on the left side. + + var v_trans_x, v_trans_y, shrink_by; // resulting translation vector for inPt + + // good reading for geometry algorithms (here: line-line intersection) + // http://geomalgorithms.com/a05-_intersect-1.html + + var v_prev_x = inPt.x - inPrev.x, + v_prev_y = inPt.y - inPrev.y; + var v_next_x = inNext.x - inPt.x, + v_next_y = inNext.y - inPt.y; + + var v_prev_lensq = ( v_prev_x * v_prev_x + v_prev_y * v_prev_y ); + + // check for collinear edges + var collinear0 = ( v_prev_x * v_next_y - v_prev_y * v_next_x ); + + if ( Math.abs( collinear0 ) > Number.EPSILON ) { + + // not collinear + + // length of vectors for normalizing + + var v_prev_len = Math.sqrt( v_prev_lensq ); + var v_next_len = Math.sqrt( v_next_x * v_next_x + v_next_y * v_next_y ); + + // shift adjacent points by unit vectors to the left + + var ptPrevShift_x = ( inPrev.x - v_prev_y / v_prev_len ); + var ptPrevShift_y = ( inPrev.y + v_prev_x / v_prev_len ); + + var ptNextShift_x = ( inNext.x - v_next_y / v_next_len ); + var ptNextShift_y = ( inNext.y + v_next_x / v_next_len ); + + // scaling factor for v_prev to intersection point + + var sf = ( ( ptNextShift_x - ptPrevShift_x ) * v_next_y - + ( ptNextShift_y - ptPrevShift_y ) * v_next_x ) / + ( v_prev_x * v_next_y - v_prev_y * v_next_x ); + + // vector from inPt to intersection point + + v_trans_x = ( ptPrevShift_x + v_prev_x * sf - inPt.x ); + v_trans_y = ( ptPrevShift_y + v_prev_y * sf - inPt.y ); + + // Don't normalize!, otherwise sharp corners become ugly + // but prevent crazy spikes + var v_trans_lensq = ( v_trans_x * v_trans_x + v_trans_y * v_trans_y ); + if ( v_trans_lensq <= 2 ) { + + return new Vector2( v_trans_x, v_trans_y ); + + } else { + + shrink_by = Math.sqrt( v_trans_lensq / 2 ); + + } + + } else { + + // handle special case of collinear edges + + var direction_eq = false; // assumes: opposite + if ( v_prev_x > Number.EPSILON ) { + + if ( v_next_x > Number.EPSILON ) { + + direction_eq = true; + + } + + } else { + + if ( v_prev_x < - Number.EPSILON ) { + + if ( v_next_x < - Number.EPSILON ) { + + direction_eq = true; + + } + + } else { + + if ( Math.sign( v_prev_y ) === Math.sign( v_next_y ) ) { + + direction_eq = true; + + } + + } + + } + + if ( direction_eq ) { + + // console.log("Warning: lines are a straight sequence"); + v_trans_x = - v_prev_y; + v_trans_y = v_prev_x; + shrink_by = Math.sqrt( v_prev_lensq ); + + } else { + + // console.log("Warning: lines are a straight spike"); + v_trans_x = v_prev_x; + v_trans_y = v_prev_y; + shrink_by = Math.sqrt( v_prev_lensq / 2 ); + + } + + } + + return new Vector2( v_trans_x / shrink_by, v_trans_y / shrink_by ); + + } + + + var contourMovements = []; + + for ( var i = 0, il = contour.length, j = il - 1, k = i + 1; i < il; i ++, j ++, k ++ ) { + + if ( j === il ) j = 0; + if ( k === il ) k = 0; + + // (j)---(i)---(k) + // console.log('i,j,k', i, j , k) + + contourMovements[ i ] = getBevelVec( contour[ i ], contour[ j ], contour[ k ] ); + + } + + var holesMovements = [], + oneHoleMovements, verticesMovements = contourMovements.concat(); + + for ( h = 0, hl = holes.length; h < hl; h ++ ) { + + ahole = holes[ h ]; + + oneHoleMovements = []; + + for ( i = 0, il = ahole.length, j = il - 1, k = i + 1; i < il; i ++, j ++, k ++ ) { + + if ( j === il ) j = 0; + if ( k === il ) k = 0; + + // (j)---(i)---(k) + oneHoleMovements[ i ] = getBevelVec( ahole[ i ], ahole[ j ], ahole[ k ] ); + + } + + holesMovements.push( oneHoleMovements ); + verticesMovements = verticesMovements.concat( oneHoleMovements ); + + } + + + // Loop bevelSegments, 1 for the front, 1 for the back + + for ( b = 0; b < bevelSegments; b ++ ) { + + //for ( b = bevelSegments; b > 0; b -- ) { + + t = b / bevelSegments; + z = bevelThickness * Math.cos( t * Math.PI / 2 ); + bs = bevelSize * Math.sin( t * Math.PI / 2 ); + + // contract shape + + for ( i = 0, il = contour.length; i < il; i ++ ) { + + vert = scalePt2( contour[ i ], contourMovements[ i ], bs ); + + v( vert.x, vert.y, - z ); + + } + + // expand holes + + for ( h = 0, hl = holes.length; h < hl; h ++ ) { + + ahole = holes[ h ]; + oneHoleMovements = holesMovements[ h ]; + + for ( i = 0, il = ahole.length; i < il; i ++ ) { + + vert = scalePt2( ahole[ i ], oneHoleMovements[ i ], bs ); + + v( vert.x, vert.y, - z ); + + } + + } + + } + + bs = bevelSize; + + // Back facing vertices + + for ( i = 0; i < vlen; i ++ ) { + + vert = bevelEnabled ? scalePt2( vertices[ i ], verticesMovements[ i ], bs ) : vertices[ i ]; + + if ( ! extrudeByPath ) { + + v( vert.x, vert.y, 0 ); + + } else { + + // v( vert.x, vert.y + extrudePts[ 0 ].y, extrudePts[ 0 ].x ); + + normal.copy( splineTube.normals[ 0 ] ).multiplyScalar( vert.x ); + binormal.copy( splineTube.binormals[ 0 ] ).multiplyScalar( vert.y ); + + position2.copy( extrudePts[ 0 ] ).add( normal ).add( binormal ); + + v( position2.x, position2.y, position2.z ); + + } + + } + + // Add stepped vertices... + // Including front facing vertices + + var s; + + for ( s = 1; s <= steps; s ++ ) { + + for ( i = 0; i < vlen; i ++ ) { + + vert = bevelEnabled ? scalePt2( vertices[ i ], verticesMovements[ i ], bs ) : vertices[ i ]; + + if ( ! extrudeByPath ) { + + v( vert.x, vert.y, depth / steps * s ); + + } else { + + // v( vert.x, vert.y + extrudePts[ s - 1 ].y, extrudePts[ s - 1 ].x ); + + normal.copy( splineTube.normals[ s ] ).multiplyScalar( vert.x ); + binormal.copy( splineTube.binormals[ s ] ).multiplyScalar( vert.y ); + + position2.copy( extrudePts[ s ] ).add( normal ).add( binormal ); + + v( position2.x, position2.y, position2.z ); + + } + + } + + } + + + // Add bevel segments planes + + //for ( b = 1; b <= bevelSegments; b ++ ) { + for ( b = bevelSegments - 1; b >= 0; b -- ) { + + t = b / bevelSegments; + z = bevelThickness * Math.cos( t * Math.PI / 2 ); + bs = bevelSize * Math.sin( t * Math.PI / 2 ); + + // contract shape + + for ( i = 0, il = contour.length; i < il; i ++ ) { + + vert = scalePt2( contour[ i ], contourMovements[ i ], bs ); + v( vert.x, vert.y, depth + z ); + + } + + // expand holes + + for ( h = 0, hl = holes.length; h < hl; h ++ ) { + + ahole = holes[ h ]; + oneHoleMovements = holesMovements[ h ]; + + for ( i = 0, il = ahole.length; i < il; i ++ ) { + + vert = scalePt2( ahole[ i ], oneHoleMovements[ i ], bs ); + + if ( ! extrudeByPath ) { + + v( vert.x, vert.y, depth + z ); + + } else { + + v( vert.x, vert.y + extrudePts[ steps - 1 ].y, extrudePts[ steps - 1 ].x + z ); + + } + + } + + } + + } + + /* Faces */ + + // Top and bottom faces + + buildLidFaces(); + + // Sides faces + + buildSideFaces(); + + + ///// Internal functions + + function buildLidFaces() { + + var start = verticesArray.length / 3; + + if ( bevelEnabled ) { + + var layer = 0; // steps + 1 + var offset = vlen * layer; + + // Bottom faces + + for ( i = 0; i < flen; i ++ ) { + + face = faces[ i ]; + f3( face[ 2 ] + offset, face[ 1 ] + offset, face[ 0 ] + offset ); + + } + + layer = steps + bevelSegments * 2; + offset = vlen * layer; + + // Top faces + + for ( i = 0; i < flen; i ++ ) { + + face = faces[ i ]; + f3( face[ 0 ] + offset, face[ 1 ] + offset, face[ 2 ] + offset ); + + } + + } else { + + // Bottom faces + + for ( i = 0; i < flen; i ++ ) { + + face = faces[ i ]; + f3( face[ 2 ], face[ 1 ], face[ 0 ] ); + + } + + // Top faces + + for ( i = 0; i < flen; i ++ ) { + + face = faces[ i ]; + f3( face[ 0 ] + vlen * steps, face[ 1 ] + vlen * steps, face[ 2 ] + vlen * steps ); + + } + + } + + scope.addGroup( start, verticesArray.length / 3 - start, 0 ); + + } + + // Create faces for the z-sides of the shape + + function buildSideFaces() { + + var start = verticesArray.length / 3; + var layeroffset = 0; + sidewalls( contour, layeroffset ); + layeroffset += contour.length; + + for ( h = 0, hl = holes.length; h < hl; h ++ ) { + + ahole = holes[ h ]; + sidewalls( ahole, layeroffset ); + + //, true + layeroffset += ahole.length; + + } + + + scope.addGroup( start, verticesArray.length / 3 - start, 1 ); + + + } + + function sidewalls( contour, layeroffset ) { + + var j, k; + i = contour.length; + + while ( -- i >= 0 ) { + + j = i; + k = i - 1; + if ( k < 0 ) k = contour.length - 1; + + //console.log('b', i,j, i-1, k,vertices.length); + + var s = 0, + sl = steps + bevelSegments * 2; + + for ( s = 0; s < sl; s ++ ) { + + var slen1 = vlen * s; + var slen2 = vlen * ( s + 1 ); + + var a = layeroffset + j + slen1, + b = layeroffset + k + slen1, + c = layeroffset + k + slen2, + d = layeroffset + j + slen2; + + f4( a, b, c, d ); + + } + + } + + } + + function v( x, y, z ) { + + placeholder.push( x ); + placeholder.push( y ); + placeholder.push( z ); + + } + + + function f3( a, b, c ) { + + addVertex( a ); + addVertex( b ); + addVertex( c ); + + var nextIndex = verticesArray.length / 3; + var uvs = uvgen.generateTopUV( scope, verticesArray, nextIndex - 3, nextIndex - 2, nextIndex - 1 ); + + addUV( uvs[ 0 ] ); + addUV( uvs[ 1 ] ); + addUV( uvs[ 2 ] ); + + } + + function f4( a, b, c, d ) { + + addVertex( a ); + addVertex( b ); + addVertex( d ); + + addVertex( b ); + addVertex( c ); + addVertex( d ); + + + var nextIndex = verticesArray.length / 3; + var uvs = uvgen.generateSideWallUV( scope, verticesArray, nextIndex - 6, nextIndex - 3, nextIndex - 2, nextIndex - 1 ); + + addUV( uvs[ 0 ] ); + addUV( uvs[ 1 ] ); + addUV( uvs[ 3 ] ); + + addUV( uvs[ 1 ] ); + addUV( uvs[ 2 ] ); + addUV( uvs[ 3 ] ); + + } + + function addVertex( index ) { + + verticesArray.push( placeholder[ index * 3 + 0 ] ); + verticesArray.push( placeholder[ index * 3 + 1 ] ); + verticesArray.push( placeholder[ index * 3 + 2 ] ); + + } + + + function addUV( vector2 ) { + + uvArray.push( vector2.x ); + uvArray.push( vector2.y ); + + } + + } + + } + + ExtrudeBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + ExtrudeBufferGeometry.prototype.constructor = ExtrudeBufferGeometry; + + ExtrudeBufferGeometry.prototype.toJSON = function () { + + var data = BufferGeometry.prototype.toJSON.call( this ); + + var shapes = this.parameters.shapes; + var options = this.parameters.options; + + return toJSON( shapes, options, data ); + + }; + + // + + var WorldUVGenerator = { + + generateTopUV: function ( geometry, vertices, indexA, indexB, indexC ) { + + var a_x = vertices[ indexA * 3 ]; + var a_y = vertices[ indexA * 3 + 1 ]; + var b_x = vertices[ indexB * 3 ]; + var b_y = vertices[ indexB * 3 + 1 ]; + var c_x = vertices[ indexC * 3 ]; + var c_y = vertices[ indexC * 3 + 1 ]; + + return [ + new Vector2( a_x, a_y ), + new Vector2( b_x, b_y ), + new Vector2( c_x, c_y ) + ]; + + }, + + generateSideWallUV: function ( geometry, vertices, indexA, indexB, indexC, indexD ) { + + var a_x = vertices[ indexA * 3 ]; + var a_y = vertices[ indexA * 3 + 1 ]; + var a_z = vertices[ indexA * 3 + 2 ]; + var b_x = vertices[ indexB * 3 ]; + var b_y = vertices[ indexB * 3 + 1 ]; + var b_z = vertices[ indexB * 3 + 2 ]; + var c_x = vertices[ indexC * 3 ]; + var c_y = vertices[ indexC * 3 + 1 ]; + var c_z = vertices[ indexC * 3 + 2 ]; + var d_x = vertices[ indexD * 3 ]; + var d_y = vertices[ indexD * 3 + 1 ]; + var d_z = vertices[ indexD * 3 + 2 ]; + + if ( Math.abs( a_y - b_y ) < 0.01 ) { + + return [ + new Vector2( a_x, 1 - a_z ), + new Vector2( b_x, 1 - b_z ), + new Vector2( c_x, 1 - c_z ), + new Vector2( d_x, 1 - d_z ) + ]; + + } else { + + return [ + new Vector2( a_y, 1 - a_z ), + new Vector2( b_y, 1 - b_z ), + new Vector2( c_y, 1 - c_z ), + new Vector2( d_y, 1 - d_z ) + ]; + + } + + } + }; + + function toJSON( shapes, options, data ) { + + // + + data.shapes = []; + + if ( Array.isArray( shapes ) ) { + + for ( var i = 0, l = shapes.length; i < l; i ++ ) { + + var shape = shapes[ i ]; + + data.shapes.push( shape.uuid ); + + } + + } else { + + data.shapes.push( shapes.uuid ); + + } + + // + + if ( options.extrudePath !== undefined ) data.options.extrudePath = options.extrudePath.toJSON(); + + return data; + + } + + /** + * @author zz85 / http://www.lab4games.net/zz85/blog + * @author alteredq / http://alteredqualia.com/ + * + * Text = 3D Text + * + * parameters = { + * font: , // font + * + * size: , // size of the text + * height: , // thickness to extrude text + * curveSegments: , // number of points on the curves + * + * bevelEnabled: , // turn on bevel + * bevelThickness: , // how deep into text bevel goes + * bevelSize: // how far from text outline is bevel + * } + */ + + // TextGeometry + + function TextGeometry( text, parameters ) { + + Geometry.call( this ); + + this.type = 'TextGeometry'; + + this.parameters = { + text: text, + parameters: parameters + }; + + this.fromBufferGeometry( new TextBufferGeometry( text, parameters ) ); + this.mergeVertices(); + + } + + TextGeometry.prototype = Object.create( Geometry.prototype ); + TextGeometry.prototype.constructor = TextGeometry; + + // TextBufferGeometry + + function TextBufferGeometry( text, parameters ) { + + parameters = parameters || {}; + + var font = parameters.font; + + if ( ! ( font && font.isFont ) ) { + + console.error( 'THREE.TextGeometry: font parameter is not an instance of THREE.Font.' ); + return new Geometry(); + + } + + var shapes = font.generateShapes( text, parameters.size, parameters.curveSegments ); + + // translate parameters to ExtrudeGeometry API + + parameters.depth = parameters.height !== undefined ? parameters.height : 50; + + // defaults + + if ( parameters.bevelThickness === undefined ) parameters.bevelThickness = 10; + if ( parameters.bevelSize === undefined ) parameters.bevelSize = 8; + if ( parameters.bevelEnabled === undefined ) parameters.bevelEnabled = false; + + ExtrudeBufferGeometry.call( this, shapes, parameters ); + + this.type = 'TextBufferGeometry'; + + } + + TextBufferGeometry.prototype = Object.create( ExtrudeBufferGeometry.prototype ); + TextBufferGeometry.prototype.constructor = TextBufferGeometry; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author benaadams / https://twitter.com/ben_a_adams + * @author Mugen87 / https://github.com/Mugen87 + */ + + // SphereGeometry + + function SphereGeometry( radius, widthSegments, heightSegments, phiStart, phiLength, thetaStart, thetaLength ) { + + Geometry.call( this ); + + this.type = 'SphereGeometry'; + + this.parameters = { + radius: radius, + widthSegments: widthSegments, + heightSegments: heightSegments, + phiStart: phiStart, + phiLength: phiLength, + thetaStart: thetaStart, + thetaLength: thetaLength + }; + + this.fromBufferGeometry( new SphereBufferGeometry( radius, widthSegments, heightSegments, phiStart, phiLength, thetaStart, thetaLength ) ); + this.mergeVertices(); + + } + + SphereGeometry.prototype = Object.create( Geometry.prototype ); + SphereGeometry.prototype.constructor = SphereGeometry; + + // SphereBufferGeometry + + function SphereBufferGeometry( radius, widthSegments, heightSegments, phiStart, phiLength, thetaStart, thetaLength ) { + + BufferGeometry.call( this ); + + this.type = 'SphereBufferGeometry'; + + this.parameters = { + radius: radius, + widthSegments: widthSegments, + heightSegments: heightSegments, + phiStart: phiStart, + phiLength: phiLength, + thetaStart: thetaStart, + thetaLength: thetaLength + }; + + radius = radius || 1; + + widthSegments = Math.max( 3, Math.floor( widthSegments ) || 8 ); + heightSegments = Math.max( 2, Math.floor( heightSegments ) || 6 ); + + phiStart = phiStart !== undefined ? phiStart : 0; + phiLength = phiLength !== undefined ? phiLength : Math.PI * 2; + + thetaStart = thetaStart !== undefined ? thetaStart : 0; + thetaLength = thetaLength !== undefined ? thetaLength : Math.PI; + + var thetaEnd = thetaStart + thetaLength; + + var ix, iy; + + var index = 0; + var grid = []; + + var vertex = new Vector3(); + var normal = new Vector3(); + + // buffers + + var indices = []; + var vertices = []; + var normals = []; + var uvs = []; + + // generate vertices, normals and uvs + + for ( iy = 0; iy <= heightSegments; iy ++ ) { + + var verticesRow = []; + + var v = iy / heightSegments; + + for ( ix = 0; ix <= widthSegments; ix ++ ) { + + var u = ix / widthSegments; + + // vertex + + vertex.x = - radius * Math.cos( phiStart + u * phiLength ) * Math.sin( thetaStart + v * thetaLength ); + vertex.y = radius * Math.cos( thetaStart + v * thetaLength ); + vertex.z = radius * Math.sin( phiStart + u * phiLength ) * Math.sin( thetaStart + v * thetaLength ); + + vertices.push( vertex.x, vertex.y, vertex.z ); + + // normal + + normal.set( vertex.x, vertex.y, vertex.z ).normalize(); + normals.push( normal.x, normal.y, normal.z ); + + // uv + + uvs.push( u, 1 - v ); + + verticesRow.push( index ++ ); + + } + + grid.push( verticesRow ); + + } + + // indices + + for ( iy = 0; iy < heightSegments; iy ++ ) { + + for ( ix = 0; ix < widthSegments; ix ++ ) { + + var a = grid[ iy ][ ix + 1 ]; + var b = grid[ iy ][ ix ]; + var c = grid[ iy + 1 ][ ix ]; + var d = grid[ iy + 1 ][ ix + 1 ]; + + if ( iy !== 0 || thetaStart > 0 ) indices.push( a, b, d ); + if ( iy !== heightSegments - 1 || thetaEnd < Math.PI ) indices.push( b, c, d ); + + } + + } + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + } + + SphereBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + SphereBufferGeometry.prototype.constructor = SphereBufferGeometry; + + /** + * @author Kaleb Murphy + * @author Mugen87 / https://github.com/Mugen87 + */ + + // RingGeometry + + function RingGeometry( innerRadius, outerRadius, thetaSegments, phiSegments, thetaStart, thetaLength ) { + + Geometry.call( this ); + + this.type = 'RingGeometry'; + + this.parameters = { + innerRadius: innerRadius, + outerRadius: outerRadius, + thetaSegments: thetaSegments, + phiSegments: phiSegments, + thetaStart: thetaStart, + thetaLength: thetaLength + }; + + this.fromBufferGeometry( new RingBufferGeometry( innerRadius, outerRadius, thetaSegments, phiSegments, thetaStart, thetaLength ) ); + this.mergeVertices(); + + } + + RingGeometry.prototype = Object.create( Geometry.prototype ); + RingGeometry.prototype.constructor = RingGeometry; + + // RingBufferGeometry + + function RingBufferGeometry( innerRadius, outerRadius, thetaSegments, phiSegments, thetaStart, thetaLength ) { + + BufferGeometry.call( this ); + + this.type = 'RingBufferGeometry'; + + this.parameters = { + innerRadius: innerRadius, + outerRadius: outerRadius, + thetaSegments: thetaSegments, + phiSegments: phiSegments, + thetaStart: thetaStart, + thetaLength: thetaLength + }; + + innerRadius = innerRadius || 0.5; + outerRadius = outerRadius || 1; + + thetaStart = thetaStart !== undefined ? thetaStart : 0; + thetaLength = thetaLength !== undefined ? thetaLength : Math.PI * 2; + + thetaSegments = thetaSegments !== undefined ? Math.max( 3, thetaSegments ) : 8; + phiSegments = phiSegments !== undefined ? Math.max( 1, phiSegments ) : 1; + + // buffers + + var indices = []; + var vertices = []; + var normals = []; + var uvs = []; + + // some helper variables + + var segment; + var radius = innerRadius; + var radiusStep = ( ( outerRadius - innerRadius ) / phiSegments ); + var vertex = new Vector3(); + var uv = new Vector2(); + var j, i; + + // generate vertices, normals and uvs + + for ( j = 0; j <= phiSegments; j ++ ) { + + for ( i = 0; i <= thetaSegments; i ++ ) { + + // values are generate from the inside of the ring to the outside + + segment = thetaStart + i / thetaSegments * thetaLength; + + // vertex + + vertex.x = radius * Math.cos( segment ); + vertex.y = radius * Math.sin( segment ); + + vertices.push( vertex.x, vertex.y, vertex.z ); + + // normal + + normals.push( 0, 0, 1 ); + + // uv + + uv.x = ( vertex.x / outerRadius + 1 ) / 2; + uv.y = ( vertex.y / outerRadius + 1 ) / 2; + + uvs.push( uv.x, uv.y ); + + } + + // increase the radius for next row of vertices + + radius += radiusStep; + + } + + // indices + + for ( j = 0; j < phiSegments; j ++ ) { + + var thetaSegmentLevel = j * ( thetaSegments + 1 ); + + for ( i = 0; i < thetaSegments; i ++ ) { + + segment = i + thetaSegmentLevel; + + var a = segment; + var b = segment + thetaSegments + 1; + var c = segment + thetaSegments + 2; + var d = segment + 1; + + // faces + + indices.push( a, b, d ); + indices.push( b, c, d ); + + } + + } + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + } + + RingBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + RingBufferGeometry.prototype.constructor = RingBufferGeometry; + + /** + * @author astrodud / http://astrodud.isgreat.org/ + * @author zz85 / https://github.com/zz85 + * @author bhouston / http://clara.io + * @author Mugen87 / https://github.com/Mugen87 + */ + + // LatheGeometry + + function LatheGeometry( points, segments, phiStart, phiLength ) { + + Geometry.call( this ); + + this.type = 'LatheGeometry'; + + this.parameters = { + points: points, + segments: segments, + phiStart: phiStart, + phiLength: phiLength + }; + + this.fromBufferGeometry( new LatheBufferGeometry( points, segments, phiStart, phiLength ) ); + this.mergeVertices(); + + } + + LatheGeometry.prototype = Object.create( Geometry.prototype ); + LatheGeometry.prototype.constructor = LatheGeometry; + + // LatheBufferGeometry + + function LatheBufferGeometry( points, segments, phiStart, phiLength ) { + + BufferGeometry.call( this ); + + this.type = 'LatheBufferGeometry'; + + this.parameters = { + points: points, + segments: segments, + phiStart: phiStart, + phiLength: phiLength + }; + + segments = Math.floor( segments ) || 12; + phiStart = phiStart || 0; + phiLength = phiLength || Math.PI * 2; + + // clamp phiLength so it's in range of [ 0, 2PI ] + + phiLength = _Math.clamp( phiLength, 0, Math.PI * 2 ); + + + // buffers + + var indices = []; + var vertices = []; + var uvs = []; + + // helper variables + + var base; + var inverseSegments = 1.0 / segments; + var vertex = new Vector3(); + var uv = new Vector2(); + var i, j; + + // generate vertices and uvs + + for ( i = 0; i <= segments; i ++ ) { + + var phi = phiStart + i * inverseSegments * phiLength; + + var sin = Math.sin( phi ); + var cos = Math.cos( phi ); + + for ( j = 0; j <= ( points.length - 1 ); j ++ ) { + + // vertex + + vertex.x = points[ j ].x * sin; + vertex.y = points[ j ].y; + vertex.z = points[ j ].x * cos; + + vertices.push( vertex.x, vertex.y, vertex.z ); + + // uv + + uv.x = i / segments; + uv.y = j / ( points.length - 1 ); + + uvs.push( uv.x, uv.y ); + + + } + + } + + // indices + + for ( i = 0; i < segments; i ++ ) { + + for ( j = 0; j < ( points.length - 1 ); j ++ ) { + + base = j + i * points.length; + + var a = base; + var b = base + points.length; + var c = base + points.length + 1; + var d = base + 1; + + // faces + + indices.push( a, b, d ); + indices.push( b, c, d ); + + } + + } + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + // generate normals + + this.computeVertexNormals(); + + // if the geometry is closed, we need to average the normals along the seam. + // because the corresponding vertices are identical (but still have different UVs). + + if ( phiLength === Math.PI * 2 ) { + + var normals = this.attributes.normal.array; + var n1 = new Vector3(); + var n2 = new Vector3(); + var n = new Vector3(); + + // this is the buffer offset for the last line of vertices + + base = segments * points.length * 3; + + for ( i = 0, j = 0; i < points.length; i ++, j += 3 ) { + + // select the normal of the vertex in the first line + + n1.x = normals[ j + 0 ]; + n1.y = normals[ j + 1 ]; + n1.z = normals[ j + 2 ]; + + // select the normal of the vertex in the last line + + n2.x = normals[ base + j + 0 ]; + n2.y = normals[ base + j + 1 ]; + n2.z = normals[ base + j + 2 ]; + + // average normals + + n.addVectors( n1, n2 ).normalize(); + + // assign the new values to both normals + + normals[ j + 0 ] = normals[ base + j + 0 ] = n.x; + normals[ j + 1 ] = normals[ base + j + 1 ] = n.y; + normals[ j + 2 ] = normals[ base + j + 2 ] = n.z; + + } + + } + + } + + LatheBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + LatheBufferGeometry.prototype.constructor = LatheBufferGeometry; + + /** + * @author jonobr1 / http://jonobr1.com + * @author Mugen87 / https://github.com/Mugen87 + */ + + // ShapeGeometry + + function ShapeGeometry( shapes, curveSegments ) { + + Geometry.call( this ); + + this.type = 'ShapeGeometry'; + + if ( typeof curveSegments === 'object' ) { + + console.warn( 'THREE.ShapeGeometry: Options parameter has been removed.' ); + + curveSegments = curveSegments.curveSegments; + + } + + this.parameters = { + shapes: shapes, + curveSegments: curveSegments + }; + + this.fromBufferGeometry( new ShapeBufferGeometry( shapes, curveSegments ) ); + this.mergeVertices(); + + } + + ShapeGeometry.prototype = Object.create( Geometry.prototype ); + ShapeGeometry.prototype.constructor = ShapeGeometry; + + ShapeGeometry.prototype.toJSON = function () { + + var data = Geometry.prototype.toJSON.call( this ); + + var shapes = this.parameters.shapes; + + return toJSON$1( shapes, data ); + + }; + + // ShapeBufferGeometry + + function ShapeBufferGeometry( shapes, curveSegments ) { + + BufferGeometry.call( this ); + + this.type = 'ShapeBufferGeometry'; + + this.parameters = { + shapes: shapes, + curveSegments: curveSegments + }; + + curveSegments = curveSegments || 12; + + // buffers + + var indices = []; + var vertices = []; + var normals = []; + var uvs = []; + + // helper variables + + var groupStart = 0; + var groupCount = 0; + + // allow single and array values for "shapes" parameter + + if ( Array.isArray( shapes ) === false ) { + + addShape( shapes ); + + } else { + + for ( var i = 0; i < shapes.length; i ++ ) { + + addShape( shapes[ i ] ); + + this.addGroup( groupStart, groupCount, i ); // enables MultiMaterial support + + groupStart += groupCount; + groupCount = 0; + + } + + } + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + + // helper functions + + function addShape( shape ) { + + var i, l, shapeHole; + + var indexOffset = vertices.length / 3; + var points = shape.extractPoints( curveSegments ); + + var shapeVertices = points.shape; + var shapeHoles = points.holes; + + // check direction of vertices + + if ( ShapeUtils.isClockWise( shapeVertices ) === false ) { + + shapeVertices = shapeVertices.reverse(); + + // also check if holes are in the opposite direction + + for ( i = 0, l = shapeHoles.length; i < l; i ++ ) { + + shapeHole = shapeHoles[ i ]; + + if ( ShapeUtils.isClockWise( shapeHole ) === true ) { + + shapeHoles[ i ] = shapeHole.reverse(); + + } + + } + + } + + var faces = ShapeUtils.triangulateShape( shapeVertices, shapeHoles ); + + // join vertices of inner and outer paths to a single array + + for ( i = 0, l = shapeHoles.length; i < l; i ++ ) { + + shapeHole = shapeHoles[ i ]; + shapeVertices = shapeVertices.concat( shapeHole ); + + } + + // vertices, normals, uvs + + for ( i = 0, l = shapeVertices.length; i < l; i ++ ) { + + var vertex = shapeVertices[ i ]; + + vertices.push( vertex.x, vertex.y, 0 ); + normals.push( 0, 0, 1 ); + uvs.push( vertex.x, vertex.y ); // world uvs + + } + + // incides + + for ( i = 0, l = faces.length; i < l; i ++ ) { + + var face = faces[ i ]; + + var a = face[ 0 ] + indexOffset; + var b = face[ 1 ] + indexOffset; + var c = face[ 2 ] + indexOffset; + + indices.push( a, b, c ); + groupCount += 3; + + } + + } + + } + + ShapeBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + ShapeBufferGeometry.prototype.constructor = ShapeBufferGeometry; + + ShapeBufferGeometry.prototype.toJSON = function () { + + var data = BufferGeometry.prototype.toJSON.call( this ); + + var shapes = this.parameters.shapes; + + return toJSON$1( shapes, data ); + + }; + + // + + function toJSON$1( shapes, data ) { + + data.shapes = []; + + if ( Array.isArray( shapes ) ) { + + for ( var i = 0, l = shapes.length; i < l; i ++ ) { + + var shape = shapes[ i ]; + + data.shapes.push( shape.uuid ); + + } + + } else { + + data.shapes.push( shapes.uuid ); + + } + + return data; + + } + + /** + * @author WestLangley / http://github.com/WestLangley + * @author Mugen87 / https://github.com/Mugen87 + */ + + function EdgesGeometry( geometry, thresholdAngle ) { + + BufferGeometry.call( this ); + + this.type = 'EdgesGeometry'; + + this.parameters = { + thresholdAngle: thresholdAngle + }; + + thresholdAngle = ( thresholdAngle !== undefined ) ? thresholdAngle : 1; + + // buffer + + var vertices = []; + + // helper variables + + var thresholdDot = Math.cos( _Math.DEG2RAD * thresholdAngle ); + var edge = [ 0, 0 ], edges = {}, edge1, edge2; + var key, keys = [ 'a', 'b', 'c' ]; + + // prepare source geometry + + var geometry2; + + if ( geometry.isBufferGeometry ) { + + geometry2 = new Geometry(); + geometry2.fromBufferGeometry( geometry ); + + } else { + + geometry2 = geometry.clone(); + + } + + geometry2.mergeVertices(); + geometry2.computeFaceNormals(); + + var sourceVertices = geometry2.vertices; + var faces = geometry2.faces; + + // now create a data structure where each entry represents an edge with its adjoining faces + + for ( var i = 0, l = faces.length; i < l; i ++ ) { + + var face = faces[ i ]; + + for ( var j = 0; j < 3; j ++ ) { + + edge1 = face[ keys[ j ] ]; + edge2 = face[ keys[ ( j + 1 ) % 3 ] ]; + edge[ 0 ] = Math.min( edge1, edge2 ); + edge[ 1 ] = Math.max( edge1, edge2 ); + + key = edge[ 0 ] + ',' + edge[ 1 ]; + + if ( edges[ key ] === undefined ) { + + edges[ key ] = { index1: edge[ 0 ], index2: edge[ 1 ], face1: i, face2: undefined }; + + } else { + + edges[ key ].face2 = i; + + } + + } + + } + + // generate vertices + + for ( key in edges ) { + + var e = edges[ key ]; + + // an edge is only rendered if the angle (in degrees) between the face normals of the adjoining faces exceeds this value. default = 1 degree. + + if ( e.face2 === undefined || faces[ e.face1 ].normal.dot( faces[ e.face2 ].normal ) <= thresholdDot ) { + + var vertex = sourceVertices[ e.index1 ]; + vertices.push( vertex.x, vertex.y, vertex.z ); + + vertex = sourceVertices[ e.index2 ]; + vertices.push( vertex.x, vertex.y, vertex.z ); + + } + + } + + // build geometry + + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + + } + + EdgesGeometry.prototype = Object.create( BufferGeometry.prototype ); + EdgesGeometry.prototype.constructor = EdgesGeometry; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author Mugen87 / https://github.com/Mugen87 + */ + + // CylinderGeometry + + function CylinderGeometry( radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength ) { + + Geometry.call( this ); + + this.type = 'CylinderGeometry'; + + this.parameters = { + radiusTop: radiusTop, + radiusBottom: radiusBottom, + height: height, + radialSegments: radialSegments, + heightSegments: heightSegments, + openEnded: openEnded, + thetaStart: thetaStart, + thetaLength: thetaLength + }; + + this.fromBufferGeometry( new CylinderBufferGeometry( radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength ) ); + this.mergeVertices(); + + } + + CylinderGeometry.prototype = Object.create( Geometry.prototype ); + CylinderGeometry.prototype.constructor = CylinderGeometry; + + // CylinderBufferGeometry + + function CylinderBufferGeometry( radiusTop, radiusBottom, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength ) { + + BufferGeometry.call( this ); + + this.type = 'CylinderBufferGeometry'; + + this.parameters = { + radiusTop: radiusTop, + radiusBottom: radiusBottom, + height: height, + radialSegments: radialSegments, + heightSegments: heightSegments, + openEnded: openEnded, + thetaStart: thetaStart, + thetaLength: thetaLength + }; + + var scope = this; + + radiusTop = radiusTop !== undefined ? radiusTop : 1; + radiusBottom = radiusBottom !== undefined ? radiusBottom : 1; + height = height || 1; + + radialSegments = Math.floor( radialSegments ) || 8; + heightSegments = Math.floor( heightSegments ) || 1; + + openEnded = openEnded !== undefined ? openEnded : false; + thetaStart = thetaStart !== undefined ? thetaStart : 0.0; + thetaLength = thetaLength !== undefined ? thetaLength : Math.PI * 2; + + // buffers + + var indices = []; + var vertices = []; + var normals = []; + var uvs = []; + + // helper variables + + var index = 0; + var indexArray = []; + var halfHeight = height / 2; + var groupStart = 0; + + // generate geometry + + generateTorso(); + + if ( openEnded === false ) { + + if ( radiusTop > 0 ) generateCap( true ); + if ( radiusBottom > 0 ) generateCap( false ); + + } + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + function generateTorso() { + + var x, y; + var normal = new Vector3(); + var vertex = new Vector3(); + + var groupCount = 0; + + // this will be used to calculate the normal + var slope = ( radiusBottom - radiusTop ) / height; + + // generate vertices, normals and uvs + + for ( y = 0; y <= heightSegments; y ++ ) { + + var indexRow = []; + + var v = y / heightSegments; + + // calculate the radius of the current row + + var radius = v * ( radiusBottom - radiusTop ) + radiusTop; + + for ( x = 0; x <= radialSegments; x ++ ) { + + var u = x / radialSegments; + + var theta = u * thetaLength + thetaStart; + + var sinTheta = Math.sin( theta ); + var cosTheta = Math.cos( theta ); + + // vertex + + vertex.x = radius * sinTheta; + vertex.y = - v * height + halfHeight; + vertex.z = radius * cosTheta; + vertices.push( vertex.x, vertex.y, vertex.z ); + + // normal + + normal.set( sinTheta, slope, cosTheta ).normalize(); + normals.push( normal.x, normal.y, normal.z ); + + // uv + + uvs.push( u, 1 - v ); + + // save index of vertex in respective row + + indexRow.push( index ++ ); + + } + + // now save vertices of the row in our index array + + indexArray.push( indexRow ); + + } + + // generate indices + + for ( x = 0; x < radialSegments; x ++ ) { + + for ( y = 0; y < heightSegments; y ++ ) { + + // we use the index array to access the correct indices + + var a = indexArray[ y ][ x ]; + var b = indexArray[ y + 1 ][ x ]; + var c = indexArray[ y + 1 ][ x + 1 ]; + var d = indexArray[ y ][ x + 1 ]; + + // faces + + indices.push( a, b, d ); + indices.push( b, c, d ); + + // update group counter + + groupCount += 6; + + } + + } + + // add a group to the geometry. this will ensure multi material support + + scope.addGroup( groupStart, groupCount, 0 ); + + // calculate new start value for groups + + groupStart += groupCount; + + } + + function generateCap( top ) { + + var x, centerIndexStart, centerIndexEnd; + + var uv = new Vector2(); + var vertex = new Vector3(); + + var groupCount = 0; + + var radius = ( top === true ) ? radiusTop : radiusBottom; + var sign = ( top === true ) ? 1 : - 1; + + // save the index of the first center vertex + centerIndexStart = index; + + // first we generate the center vertex data of the cap. + // because the geometry needs one set of uvs per face, + // we must generate a center vertex per face/segment + + for ( x = 1; x <= radialSegments; x ++ ) { + + // vertex + + vertices.push( 0, halfHeight * sign, 0 ); + + // normal + + normals.push( 0, sign, 0 ); + + // uv + + uvs.push( 0.5, 0.5 ); + + // increase index + + index ++; + + } + + // save the index of the last center vertex + + centerIndexEnd = index; + + // now we generate the surrounding vertices, normals and uvs + + for ( x = 0; x <= radialSegments; x ++ ) { + + var u = x / radialSegments; + var theta = u * thetaLength + thetaStart; + + var cosTheta = Math.cos( theta ); + var sinTheta = Math.sin( theta ); + + // vertex + + vertex.x = radius * sinTheta; + vertex.y = halfHeight * sign; + vertex.z = radius * cosTheta; + vertices.push( vertex.x, vertex.y, vertex.z ); + + // normal + + normals.push( 0, sign, 0 ); + + // uv + + uv.x = ( cosTheta * 0.5 ) + 0.5; + uv.y = ( sinTheta * 0.5 * sign ) + 0.5; + uvs.push( uv.x, uv.y ); + + // increase index + + index ++; + + } + + // generate indices + + for ( x = 0; x < radialSegments; x ++ ) { + + var c = centerIndexStart + x; + var i = centerIndexEnd + x; + + if ( top === true ) { + + // face top + + indices.push( i, i + 1, c ); + + } else { + + // face bottom + + indices.push( i + 1, i, c ); + + } + + groupCount += 3; + + } + + // add a group to the geometry. this will ensure multi material support + + scope.addGroup( groupStart, groupCount, top === true ? 1 : 2 ); + + // calculate new start value for groups + + groupStart += groupCount; + + } + + } + + CylinderBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + CylinderBufferGeometry.prototype.constructor = CylinderBufferGeometry; + + /** + * @author abelnation / http://github.com/abelnation + */ + + // ConeGeometry + + function ConeGeometry( radius, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength ) { + + CylinderGeometry.call( this, 0, radius, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength ); + + this.type = 'ConeGeometry'; + + this.parameters = { + radius: radius, + height: height, + radialSegments: radialSegments, + heightSegments: heightSegments, + openEnded: openEnded, + thetaStart: thetaStart, + thetaLength: thetaLength + }; + + } + + ConeGeometry.prototype = Object.create( CylinderGeometry.prototype ); + ConeGeometry.prototype.constructor = ConeGeometry; + + // ConeBufferGeometry + + function ConeBufferGeometry( radius, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength ) { + + CylinderBufferGeometry.call( this, 0, radius, height, radialSegments, heightSegments, openEnded, thetaStart, thetaLength ); + + this.type = 'ConeBufferGeometry'; + + this.parameters = { + radius: radius, + height: height, + radialSegments: radialSegments, + heightSegments: heightSegments, + openEnded: openEnded, + thetaStart: thetaStart, + thetaLength: thetaLength + }; + + } + + ConeBufferGeometry.prototype = Object.create( CylinderBufferGeometry.prototype ); + ConeBufferGeometry.prototype.constructor = ConeBufferGeometry; + + /** + * @author benaadams / https://twitter.com/ben_a_adams + * @author Mugen87 / https://github.com/Mugen87 + * @author hughes + */ + + // CircleGeometry + + function CircleGeometry( radius, segments, thetaStart, thetaLength ) { + + Geometry.call( this ); + + this.type = 'CircleGeometry'; + + this.parameters = { + radius: radius, + segments: segments, + thetaStart: thetaStart, + thetaLength: thetaLength + }; + + this.fromBufferGeometry( new CircleBufferGeometry( radius, segments, thetaStart, thetaLength ) ); + this.mergeVertices(); + + } + + CircleGeometry.prototype = Object.create( Geometry.prototype ); + CircleGeometry.prototype.constructor = CircleGeometry; + + // CircleBufferGeometry + + function CircleBufferGeometry( radius, segments, thetaStart, thetaLength ) { + + BufferGeometry.call( this ); + + this.type = 'CircleBufferGeometry'; + + this.parameters = { + radius: radius, + segments: segments, + thetaStart: thetaStart, + thetaLength: thetaLength + }; + + radius = radius || 1; + segments = segments !== undefined ? Math.max( 3, segments ) : 8; + + thetaStart = thetaStart !== undefined ? thetaStart : 0; + thetaLength = thetaLength !== undefined ? thetaLength : Math.PI * 2; + + // buffers + + var indices = []; + var vertices = []; + var normals = []; + var uvs = []; + + // helper variables + + var i, s; + var vertex = new Vector3(); + var uv = new Vector2(); + + // center point + + vertices.push( 0, 0, 0 ); + normals.push( 0, 0, 1 ); + uvs.push( 0.5, 0.5 ); + + for ( s = 0, i = 3; s <= segments; s ++, i += 3 ) { + + var segment = thetaStart + s / segments * thetaLength; + + // vertex + + vertex.x = radius * Math.cos( segment ); + vertex.y = radius * Math.sin( segment ); + + vertices.push( vertex.x, vertex.y, vertex.z ); + + // normal + + normals.push( 0, 0, 1 ); + + // uvs + + uv.x = ( vertices[ i ] / radius + 1 ) / 2; + uv.y = ( vertices[ i + 1 ] / radius + 1 ) / 2; + + uvs.push( uv.x, uv.y ); + + } + + // indices + + for ( i = 1; i <= segments; i ++ ) { + + indices.push( i, i + 1, 0 ); + + } + + // build geometry + + this.setIndex( indices ); + this.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + this.addAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) ); + this.addAttribute( 'uv', new Float32BufferAttribute( uvs, 2 ) ); + + } + + CircleBufferGeometry.prototype = Object.create( BufferGeometry.prototype ); + CircleBufferGeometry.prototype.constructor = CircleBufferGeometry; + + + + var Geometries = /*#__PURE__*/Object.freeze({ + WireframeGeometry: WireframeGeometry, + ParametricGeometry: ParametricGeometry, + ParametricBufferGeometry: ParametricBufferGeometry, + TetrahedronGeometry: TetrahedronGeometry, + TetrahedronBufferGeometry: TetrahedronBufferGeometry, + OctahedronGeometry: OctahedronGeometry, + OctahedronBufferGeometry: OctahedronBufferGeometry, + IcosahedronGeometry: IcosahedronGeometry, + IcosahedronBufferGeometry: IcosahedronBufferGeometry, + DodecahedronGeometry: DodecahedronGeometry, + DodecahedronBufferGeometry: DodecahedronBufferGeometry, + PolyhedronGeometry: PolyhedronGeometry, + PolyhedronBufferGeometry: PolyhedronBufferGeometry, + TubeGeometry: TubeGeometry, + TubeBufferGeometry: TubeBufferGeometry, + TorusKnotGeometry: TorusKnotGeometry, + TorusKnotBufferGeometry: TorusKnotBufferGeometry, + TorusGeometry: TorusGeometry, + TorusBufferGeometry: TorusBufferGeometry, + TextGeometry: TextGeometry, + TextBufferGeometry: TextBufferGeometry, + SphereGeometry: SphereGeometry, + SphereBufferGeometry: SphereBufferGeometry, + RingGeometry: RingGeometry, + RingBufferGeometry: RingBufferGeometry, + PlaneGeometry: PlaneGeometry, + PlaneBufferGeometry: PlaneBufferGeometry, + LatheGeometry: LatheGeometry, + LatheBufferGeometry: LatheBufferGeometry, + ShapeGeometry: ShapeGeometry, + ShapeBufferGeometry: ShapeBufferGeometry, + ExtrudeGeometry: ExtrudeGeometry, + ExtrudeBufferGeometry: ExtrudeBufferGeometry, + EdgesGeometry: EdgesGeometry, + ConeGeometry: ConeGeometry, + ConeBufferGeometry: ConeBufferGeometry, + CylinderGeometry: CylinderGeometry, + CylinderBufferGeometry: CylinderBufferGeometry, + CircleGeometry: CircleGeometry, + CircleBufferGeometry: CircleBufferGeometry, + BoxGeometry: BoxGeometry, + BoxBufferGeometry: BoxBufferGeometry + }); + + /** + * @author mrdoob / http://mrdoob.com/ + * + * parameters = { + * color: + * } + */ + + function ShadowMaterial( parameters ) { + + Material.call( this ); + + this.type = 'ShadowMaterial'; + + this.color = new Color( 0x000000 ); + this.transparent = true; + + this.setValues( parameters ); + + } + + ShadowMaterial.prototype = Object.create( Material.prototype ); + ShadowMaterial.prototype.constructor = ShadowMaterial; + + ShadowMaterial.prototype.isShadowMaterial = true; + + ShadowMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.color.copy( source.color ); + + return this; + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function RawShaderMaterial( parameters ) { + + ShaderMaterial.call( this, parameters ); + + this.type = 'RawShaderMaterial'; + + } + + RawShaderMaterial.prototype = Object.create( ShaderMaterial.prototype ); + RawShaderMaterial.prototype.constructor = RawShaderMaterial; + + RawShaderMaterial.prototype.isRawShaderMaterial = true; + + /** + * @author WestLangley / http://github.com/WestLangley + * + * parameters = { + * color: , + * roughness: , + * metalness: , + * opacity: , + * + * map: new THREE.Texture( ), + * + * lightMap: new THREE.Texture( ), + * lightMapIntensity: + * + * aoMap: new THREE.Texture( ), + * aoMapIntensity: + * + * emissive: , + * emissiveIntensity: + * emissiveMap: new THREE.Texture( ), + * + * bumpMap: new THREE.Texture( ), + * bumpScale: , + * + * normalMap: new THREE.Texture( ), + * normalScale: , + * + * displacementMap: new THREE.Texture( ), + * displacementScale: , + * displacementBias: , + * + * roughnessMap: new THREE.Texture( ), + * + * metalnessMap: new THREE.Texture( ), + * + * alphaMap: new THREE.Texture( ), + * + * envMap: new THREE.CubeTexture( [posx, negx, posy, negy, posz, negz] ), + * envMapIntensity: + * + * refractionRatio: , + * + * wireframe: , + * wireframeLinewidth: , + * + * skinning: , + * morphTargets: , + * morphNormals: + * } + */ + + function MeshStandardMaterial( parameters ) { + + Material.call( this ); + + this.defines = { 'STANDARD': '' }; + + this.type = 'MeshStandardMaterial'; + + this.color = new Color( 0xffffff ); // diffuse + this.roughness = 0.5; + this.metalness = 0.5; + + this.map = null; + + this.lightMap = null; + this.lightMapIntensity = 1.0; + + this.aoMap = null; + this.aoMapIntensity = 1.0; + + this.emissive = new Color( 0x000000 ); + this.emissiveIntensity = 1.0; + this.emissiveMap = null; + + this.bumpMap = null; + this.bumpScale = 1; + + this.normalMap = null; + this.normalScale = new Vector2( 1, 1 ); + + this.displacementMap = null; + this.displacementScale = 1; + this.displacementBias = 0; + + this.roughnessMap = null; + + this.metalnessMap = null; + + this.alphaMap = null; + + this.envMap = null; + this.envMapIntensity = 1.0; + + this.refractionRatio = 0.98; + + this.wireframe = false; + this.wireframeLinewidth = 1; + this.wireframeLinecap = 'round'; + this.wireframeLinejoin = 'round'; + + this.skinning = false; + this.morphTargets = false; + this.morphNormals = false; + + this.setValues( parameters ); + + } + + MeshStandardMaterial.prototype = Object.create( Material.prototype ); + MeshStandardMaterial.prototype.constructor = MeshStandardMaterial; + + MeshStandardMaterial.prototype.isMeshStandardMaterial = true; + + MeshStandardMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.defines = { 'STANDARD': '' }; + + this.color.copy( source.color ); + this.roughness = source.roughness; + this.metalness = source.metalness; + + this.map = source.map; + + this.lightMap = source.lightMap; + this.lightMapIntensity = source.lightMapIntensity; + + this.aoMap = source.aoMap; + this.aoMapIntensity = source.aoMapIntensity; + + this.emissive.copy( source.emissive ); + this.emissiveMap = source.emissiveMap; + this.emissiveIntensity = source.emissiveIntensity; + + this.bumpMap = source.bumpMap; + this.bumpScale = source.bumpScale; + + this.normalMap = source.normalMap; + this.normalScale.copy( source.normalScale ); + + this.displacementMap = source.displacementMap; + this.displacementScale = source.displacementScale; + this.displacementBias = source.displacementBias; + + this.roughnessMap = source.roughnessMap; + + this.metalnessMap = source.metalnessMap; + + this.alphaMap = source.alphaMap; + + this.envMap = source.envMap; + this.envMapIntensity = source.envMapIntensity; + + this.refractionRatio = source.refractionRatio; + + this.wireframe = source.wireframe; + this.wireframeLinewidth = source.wireframeLinewidth; + this.wireframeLinecap = source.wireframeLinecap; + this.wireframeLinejoin = source.wireframeLinejoin; + + this.skinning = source.skinning; + this.morphTargets = source.morphTargets; + this.morphNormals = source.morphNormals; + + return this; + + }; + + /** + * @author WestLangley / http://github.com/WestLangley + * + * parameters = { + * reflectivity: + * } + */ + + function MeshPhysicalMaterial( parameters ) { + + MeshStandardMaterial.call( this ); + + this.defines = { 'PHYSICAL': '' }; + + this.type = 'MeshPhysicalMaterial'; + + this.reflectivity = 0.5; // maps to F0 = 0.04 + + this.clearCoat = 0.0; + this.clearCoatRoughness = 0.0; + + this.setValues( parameters ); + + } + + MeshPhysicalMaterial.prototype = Object.create( MeshStandardMaterial.prototype ); + MeshPhysicalMaterial.prototype.constructor = MeshPhysicalMaterial; + + MeshPhysicalMaterial.prototype.isMeshPhysicalMaterial = true; + + MeshPhysicalMaterial.prototype.copy = function ( source ) { + + MeshStandardMaterial.prototype.copy.call( this, source ); + + this.defines = { 'PHYSICAL': '' }; + + this.reflectivity = source.reflectivity; + + this.clearCoat = source.clearCoat; + this.clearCoatRoughness = source.clearCoatRoughness; + + return this; + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + * + * parameters = { + * color: , + * specular: , + * shininess: , + * opacity: , + * + * map: new THREE.Texture( ), + * + * lightMap: new THREE.Texture( ), + * lightMapIntensity: + * + * aoMap: new THREE.Texture( ), + * aoMapIntensity: + * + * emissive: , + * emissiveIntensity: + * emissiveMap: new THREE.Texture( ), + * + * bumpMap: new THREE.Texture( ), + * bumpScale: , + * + * normalMap: new THREE.Texture( ), + * normalScale: , + * + * displacementMap: new THREE.Texture( ), + * displacementScale: , + * displacementBias: , + * + * specularMap: new THREE.Texture( ), + * + * alphaMap: new THREE.Texture( ), + * + * envMap: new THREE.CubeTexture( [posx, negx, posy, negy, posz, negz] ), + * combine: THREE.Multiply, + * reflectivity: , + * refractionRatio: , + * + * wireframe: , + * wireframeLinewidth: , + * + * skinning: , + * morphTargets: , + * morphNormals: + * } + */ + + function MeshPhongMaterial( parameters ) { + + Material.call( this ); + + this.type = 'MeshPhongMaterial'; + + this.color = new Color( 0xffffff ); // diffuse + this.specular = new Color( 0x111111 ); + this.shininess = 30; + + this.map = null; + + this.lightMap = null; + this.lightMapIntensity = 1.0; + + this.aoMap = null; + this.aoMapIntensity = 1.0; + + this.emissive = new Color( 0x000000 ); + this.emissiveIntensity = 1.0; + this.emissiveMap = null; + + this.bumpMap = null; + this.bumpScale = 1; + + this.normalMap = null; + this.normalScale = new Vector2( 1, 1 ); + + this.displacementMap = null; + this.displacementScale = 1; + this.displacementBias = 0; + + this.specularMap = null; + + this.alphaMap = null; + + this.envMap = null; + this.combine = MultiplyOperation; + this.reflectivity = 1; + this.refractionRatio = 0.98; + + this.wireframe = false; + this.wireframeLinewidth = 1; + this.wireframeLinecap = 'round'; + this.wireframeLinejoin = 'round'; + + this.skinning = false; + this.morphTargets = false; + this.morphNormals = false; + + this.setValues( parameters ); + + } + + MeshPhongMaterial.prototype = Object.create( Material.prototype ); + MeshPhongMaterial.prototype.constructor = MeshPhongMaterial; + + MeshPhongMaterial.prototype.isMeshPhongMaterial = true; + + MeshPhongMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.color.copy( source.color ); + this.specular.copy( source.specular ); + this.shininess = source.shininess; + + this.map = source.map; + + this.lightMap = source.lightMap; + this.lightMapIntensity = source.lightMapIntensity; + + this.aoMap = source.aoMap; + this.aoMapIntensity = source.aoMapIntensity; + + this.emissive.copy( source.emissive ); + this.emissiveMap = source.emissiveMap; + this.emissiveIntensity = source.emissiveIntensity; + + this.bumpMap = source.bumpMap; + this.bumpScale = source.bumpScale; + + this.normalMap = source.normalMap; + this.normalScale.copy( source.normalScale ); + + this.displacementMap = source.displacementMap; + this.displacementScale = source.displacementScale; + this.displacementBias = source.displacementBias; + + this.specularMap = source.specularMap; + + this.alphaMap = source.alphaMap; + + this.envMap = source.envMap; + this.combine = source.combine; + this.reflectivity = source.reflectivity; + this.refractionRatio = source.refractionRatio; + + this.wireframe = source.wireframe; + this.wireframeLinewidth = source.wireframeLinewidth; + this.wireframeLinecap = source.wireframeLinecap; + this.wireframeLinejoin = source.wireframeLinejoin; + + this.skinning = source.skinning; + this.morphTargets = source.morphTargets; + this.morphNormals = source.morphNormals; + + return this; + + }; + + /** + * @author takahirox / http://github.com/takahirox + * + * parameters = { + * gradientMap: new THREE.Texture( ) + * } + */ + + function MeshToonMaterial( parameters ) { + + MeshPhongMaterial.call( this ); + + this.defines = { 'TOON': '' }; + + this.type = 'MeshToonMaterial'; + + this.gradientMap = null; + + this.setValues( parameters ); + + } + + MeshToonMaterial.prototype = Object.create( MeshPhongMaterial.prototype ); + MeshToonMaterial.prototype.constructor = MeshToonMaterial; + + MeshToonMaterial.prototype.isMeshToonMaterial = true; + + MeshToonMaterial.prototype.copy = function ( source ) { + + MeshPhongMaterial.prototype.copy.call( this, source ); + + this.gradientMap = source.gradientMap; + + return this; + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author WestLangley / http://github.com/WestLangley + * + * parameters = { + * opacity: , + * + * bumpMap: new THREE.Texture( ), + * bumpScale: , + * + * normalMap: new THREE.Texture( ), + * normalScale: , + * + * displacementMap: new THREE.Texture( ), + * displacementScale: , + * displacementBias: , + * + * wireframe: , + * wireframeLinewidth: + * + * skinning: , + * morphTargets: , + * morphNormals: + * } + */ + + function MeshNormalMaterial( parameters ) { + + Material.call( this ); + + this.type = 'MeshNormalMaterial'; + + this.bumpMap = null; + this.bumpScale = 1; + + this.normalMap = null; + this.normalScale = new Vector2( 1, 1 ); + + this.displacementMap = null; + this.displacementScale = 1; + this.displacementBias = 0; + + this.wireframe = false; + this.wireframeLinewidth = 1; + + this.fog = false; + this.lights = false; + + this.skinning = false; + this.morphTargets = false; + this.morphNormals = false; + + this.setValues( parameters ); + + } + + MeshNormalMaterial.prototype = Object.create( Material.prototype ); + MeshNormalMaterial.prototype.constructor = MeshNormalMaterial; + + MeshNormalMaterial.prototype.isMeshNormalMaterial = true; + + MeshNormalMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.bumpMap = source.bumpMap; + this.bumpScale = source.bumpScale; + + this.normalMap = source.normalMap; + this.normalScale.copy( source.normalScale ); + + this.displacementMap = source.displacementMap; + this.displacementScale = source.displacementScale; + this.displacementBias = source.displacementBias; + + this.wireframe = source.wireframe; + this.wireframeLinewidth = source.wireframeLinewidth; + + this.skinning = source.skinning; + this.morphTargets = source.morphTargets; + this.morphNormals = source.morphNormals; + + return this; + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + * + * parameters = { + * color: , + * opacity: , + * + * map: new THREE.Texture( ), + * + * lightMap: new THREE.Texture( ), + * lightMapIntensity: + * + * aoMap: new THREE.Texture( ), + * aoMapIntensity: + * + * emissive: , + * emissiveIntensity: + * emissiveMap: new THREE.Texture( ), + * + * specularMap: new THREE.Texture( ), + * + * alphaMap: new THREE.Texture( ), + * + * envMap: new THREE.CubeTexture( [posx, negx, posy, negy, posz, negz] ), + * combine: THREE.Multiply, + * reflectivity: , + * refractionRatio: , + * + * wireframe: , + * wireframeLinewidth: , + * + * skinning: , + * morphTargets: , + * morphNormals: + * } + */ + + function MeshLambertMaterial( parameters ) { + + Material.call( this ); + + this.type = 'MeshLambertMaterial'; + + this.color = new Color( 0xffffff ); // diffuse + + this.map = null; + + this.lightMap = null; + this.lightMapIntensity = 1.0; + + this.aoMap = null; + this.aoMapIntensity = 1.0; + + this.emissive = new Color( 0x000000 ); + this.emissiveIntensity = 1.0; + this.emissiveMap = null; + + this.specularMap = null; + + this.alphaMap = null; + + this.envMap = null; + this.combine = MultiplyOperation; + this.reflectivity = 1; + this.refractionRatio = 0.98; + + this.wireframe = false; + this.wireframeLinewidth = 1; + this.wireframeLinecap = 'round'; + this.wireframeLinejoin = 'round'; + + this.skinning = false; + this.morphTargets = false; + this.morphNormals = false; + + this.setValues( parameters ); + + } + + MeshLambertMaterial.prototype = Object.create( Material.prototype ); + MeshLambertMaterial.prototype.constructor = MeshLambertMaterial; + + MeshLambertMaterial.prototype.isMeshLambertMaterial = true; + + MeshLambertMaterial.prototype.copy = function ( source ) { + + Material.prototype.copy.call( this, source ); + + this.color.copy( source.color ); + + this.map = source.map; + + this.lightMap = source.lightMap; + this.lightMapIntensity = source.lightMapIntensity; + + this.aoMap = source.aoMap; + this.aoMapIntensity = source.aoMapIntensity; + + this.emissive.copy( source.emissive ); + this.emissiveMap = source.emissiveMap; + this.emissiveIntensity = source.emissiveIntensity; + + this.specularMap = source.specularMap; + + this.alphaMap = source.alphaMap; + + this.envMap = source.envMap; + this.combine = source.combine; + this.reflectivity = source.reflectivity; + this.refractionRatio = source.refractionRatio; + + this.wireframe = source.wireframe; + this.wireframeLinewidth = source.wireframeLinewidth; + this.wireframeLinecap = source.wireframeLinecap; + this.wireframeLinejoin = source.wireframeLinejoin; + + this.skinning = source.skinning; + this.morphTargets = source.morphTargets; + this.morphNormals = source.morphNormals; + + return this; + + }; + + /** + * @author alteredq / http://alteredqualia.com/ + * + * parameters = { + * color: , + * opacity: , + * + * linewidth: , + * + * scale: , + * dashSize: , + * gapSize: + * } + */ + + function LineDashedMaterial( parameters ) { + + LineBasicMaterial.call( this ); + + this.type = 'LineDashedMaterial'; + + this.scale = 1; + this.dashSize = 3; + this.gapSize = 1; + + this.setValues( parameters ); + + } + + LineDashedMaterial.prototype = Object.create( LineBasicMaterial.prototype ); + LineDashedMaterial.prototype.constructor = LineDashedMaterial; + + LineDashedMaterial.prototype.isLineDashedMaterial = true; + + LineDashedMaterial.prototype.copy = function ( source ) { + + LineBasicMaterial.prototype.copy.call( this, source ); + + this.scale = source.scale; + this.dashSize = source.dashSize; + this.gapSize = source.gapSize; + + return this; + + }; + + + + var Materials = /*#__PURE__*/Object.freeze({ + ShadowMaterial: ShadowMaterial, + SpriteMaterial: SpriteMaterial, + RawShaderMaterial: RawShaderMaterial, + ShaderMaterial: ShaderMaterial, + PointsMaterial: PointsMaterial, + MeshPhysicalMaterial: MeshPhysicalMaterial, + MeshStandardMaterial: MeshStandardMaterial, + MeshPhongMaterial: MeshPhongMaterial, + MeshToonMaterial: MeshToonMaterial, + MeshNormalMaterial: MeshNormalMaterial, + MeshLambertMaterial: MeshLambertMaterial, + MeshDepthMaterial: MeshDepthMaterial, + MeshDistanceMaterial: MeshDistanceMaterial, + MeshBasicMaterial: MeshBasicMaterial, + LineDashedMaterial: LineDashedMaterial, + LineBasicMaterial: LineBasicMaterial, + Material: Material + }); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + var Cache = { + + enabled: false, + + files: {}, + + add: function ( key, file ) { + + if ( this.enabled === false ) return; + + // console.log( 'THREE.Cache', 'Adding key:', key ); + + this.files[ key ] = file; + + }, + + get: function ( key ) { + + if ( this.enabled === false ) return; + + // console.log( 'THREE.Cache', 'Checking key:', key ); + + return this.files[ key ]; + + }, + + remove: function ( key ) { + + delete this.files[ key ]; + + }, + + clear: function () { + + this.files = {}; + + } + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function LoadingManager( onLoad, onProgress, onError ) { + + var scope = this; + + var isLoading = false; + var itemsLoaded = 0; + var itemsTotal = 0; + var urlModifier = undefined; + + this.onStart = undefined; + this.onLoad = onLoad; + this.onProgress = onProgress; + this.onError = onError; + + this.itemStart = function ( url ) { + + itemsTotal ++; + + if ( isLoading === false ) { + + if ( scope.onStart !== undefined ) { + + scope.onStart( url, itemsLoaded, itemsTotal ); + + } + + } + + isLoading = true; + + }; + + this.itemEnd = function ( url ) { + + itemsLoaded ++; + + if ( scope.onProgress !== undefined ) { + + scope.onProgress( url, itemsLoaded, itemsTotal ); + + } + + if ( itemsLoaded === itemsTotal ) { + + isLoading = false; + + if ( scope.onLoad !== undefined ) { + + scope.onLoad(); + + } + + } + + }; + + this.itemError = function ( url ) { + + if ( scope.onError !== undefined ) { + + scope.onError( url ); + + } + + }; + + this.resolveURL = function ( url ) { + + if ( urlModifier ) { + + return urlModifier( url ); + + } + + return url; + + }; + + this.setURLModifier = function ( transform ) { + + urlModifier = transform; + return this; + + }; + + } + + var DefaultLoadingManager = new LoadingManager(); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + var loading = {}; + + function FileLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + + } + + Object.assign( FileLoader.prototype, { + + load: function ( url, onLoad, onProgress, onError ) { + + if ( url === undefined ) url = ''; + + if ( this.path !== undefined ) url = this.path + url; + + url = this.manager.resolveURL( url ); + + var scope = this; + + var cached = Cache.get( url ); + + if ( cached !== undefined ) { + + scope.manager.itemStart( url ); + + setTimeout( function () { + + if ( onLoad ) onLoad( cached ); + + scope.manager.itemEnd( url ); + + }, 0 ); + + return cached; + + } + + // Check if request is duplicate + + if ( loading[ url ] !== undefined ) { + + loading[ url ].push( { + + onLoad: onLoad, + onProgress: onProgress, + onError: onError + + } ); + + return; + + } + + // Check for data: URI + var dataUriRegex = /^data:(.*?)(;base64)?,(.*)$/; + var dataUriRegexResult = url.match( dataUriRegex ); + + // Safari can not handle Data URIs through XMLHttpRequest so process manually + if ( dataUriRegexResult ) { + + var mimeType = dataUriRegexResult[ 1 ]; + var isBase64 = !! dataUriRegexResult[ 2 ]; + var data = dataUriRegexResult[ 3 ]; + + data = window.decodeURIComponent( data ); + + if ( isBase64 ) data = window.atob( data ); + + try { + + var response; + var responseType = ( this.responseType || '' ).toLowerCase(); + + switch ( responseType ) { + + case 'arraybuffer': + case 'blob': + + var view = new Uint8Array( data.length ); + + for ( var i = 0; i < data.length; i ++ ) { + + view[ i ] = data.charCodeAt( i ); + + } + + if ( responseType === 'blob' ) { + + response = new Blob( [ view.buffer ], { type: mimeType } ); + + } else { + + response = view.buffer; + + } + + break; + + case 'document': + + var parser = new DOMParser(); + response = parser.parseFromString( data, mimeType ); + + break; + + case 'json': + + response = JSON.parse( data ); + + break; + + default: // 'text' or other + + response = data; + + break; + + } + + // Wait for next browser tick like standard XMLHttpRequest event dispatching does + window.setTimeout( function () { + + if ( onLoad ) onLoad( response ); + + scope.manager.itemEnd( url ); + + }, 0 ); + + } catch ( error ) { + + // Wait for next browser tick like standard XMLHttpRequest event dispatching does + window.setTimeout( function () { + + if ( onError ) onError( error ); + + scope.manager.itemEnd( url ); + scope.manager.itemError( url ); + + }, 0 ); + + } + + } else { + + // Initialise array for duplicate requests + + loading[ url ] = []; + + loading[ url ].push( { + + onLoad: onLoad, + onProgress: onProgress, + onError: onError + + } ); + + var request = new XMLHttpRequest(); + + request.open( 'GET', url, true ); + + request.addEventListener( 'load', function ( event ) { + + var response = this.response; + + Cache.add( url, response ); + + var callbacks = loading[ url ]; + + delete loading[ url ]; + + if ( this.status === 200 || this.status === 0 ) { + + // Some browsers return HTTP Status 0 when using non-http protocol + // e.g. 'file://' or 'data://'. Handle as success. + + if ( this.status === 0 ) console.warn( 'THREE.FileLoader: HTTP Status 0 received.' ); + + for ( var i = 0, il = callbacks.length; i < il; i ++ ) { + + var callback = callbacks[ i ]; + if ( callback.onLoad ) callback.onLoad( response ); + + } + + scope.manager.itemEnd( url ); + + } else { + + for ( var i = 0, il = callbacks.length; i < il; i ++ ) { + + var callback = callbacks[ i ]; + if ( callback.onError ) callback.onError( event ); + + } + + scope.manager.itemEnd( url ); + scope.manager.itemError( url ); + + } + + }, false ); + + request.addEventListener( 'progress', function ( event ) { + + var callbacks = loading[ url ]; + + for ( var i = 0, il = callbacks.length; i < il; i ++ ) { + + var callback = callbacks[ i ]; + if ( callback.onProgress ) callback.onProgress( event ); + + } + + }, false ); + + request.addEventListener( 'error', function ( event ) { + + var callbacks = loading[ url ]; + + delete loading[ url ]; + + for ( var i = 0, il = callbacks.length; i < il; i ++ ) { + + var callback = callbacks[ i ]; + if ( callback.onError ) callback.onError( event ); + + } + + scope.manager.itemEnd( url ); + scope.manager.itemError( url ); + + }, false ); + + if ( this.responseType !== undefined ) request.responseType = this.responseType; + if ( this.withCredentials !== undefined ) request.withCredentials = this.withCredentials; + + if ( request.overrideMimeType ) request.overrideMimeType( this.mimeType !== undefined ? this.mimeType : 'text/plain' ); + + for ( var header in this.requestHeader ) { + + request.setRequestHeader( header, this.requestHeader[ header ] ); + + } + + request.send( null ); + + } + + scope.manager.itemStart( url ); + + return request; + + }, + + setPath: function ( value ) { + + this.path = value; + return this; + + }, + + setResponseType: function ( value ) { + + this.responseType = value; + return this; + + }, + + setWithCredentials: function ( value ) { + + this.withCredentials = value; + return this; + + }, + + setMimeType: function ( value ) { + + this.mimeType = value; + return this; + + }, + + setRequestHeader: function ( value ) { + + this.requestHeader = value; + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * + * Abstract Base class to block based textures loader (dds, pvr, ...) + */ + + function CompressedTextureLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + + // override in sub classes + this._parser = null; + + } + + Object.assign( CompressedTextureLoader.prototype, { + + load: function ( url, onLoad, onProgress, onError ) { + + var scope = this; + + var images = []; + + var texture = new CompressedTexture(); + texture.image = images; + + var loader = new FileLoader( this.manager ); + loader.setPath( this.path ); + loader.setResponseType( 'arraybuffer' ); + + function loadTexture( i ) { + + loader.load( url[ i ], function ( buffer ) { + + var texDatas = scope._parser( buffer, true ); + + images[ i ] = { + width: texDatas.width, + height: texDatas.height, + format: texDatas.format, + mipmaps: texDatas.mipmaps + }; + + loaded += 1; + + if ( loaded === 6 ) { + + if ( texDatas.mipmapCount === 1 ) + texture.minFilter = LinearFilter; + + texture.format = texDatas.format; + texture.needsUpdate = true; + + if ( onLoad ) onLoad( texture ); + + } + + }, onProgress, onError ); + + } + + if ( Array.isArray( url ) ) { + + var loaded = 0; + + for ( var i = 0, il = url.length; i < il; ++ i ) { + + loadTexture( i ); + + } + + } else { + + // compressed cubemap texture stored in a single DDS file + + loader.load( url, function ( buffer ) { + + var texDatas = scope._parser( buffer, true ); + + if ( texDatas.isCubemap ) { + + var faces = texDatas.mipmaps.length / texDatas.mipmapCount; + + for ( var f = 0; f < faces; f ++ ) { + + images[ f ] = { mipmaps: [] }; + + for ( var i = 0; i < texDatas.mipmapCount; i ++ ) { + + images[ f ].mipmaps.push( texDatas.mipmaps[ f * texDatas.mipmapCount + i ] ); + images[ f ].format = texDatas.format; + images[ f ].width = texDatas.width; + images[ f ].height = texDatas.height; + + } + + } + + } else { + + texture.image.width = texDatas.width; + texture.image.height = texDatas.height; + texture.mipmaps = texDatas.mipmaps; + + } + + if ( texDatas.mipmapCount === 1 ) { + + texture.minFilter = LinearFilter; + + } + + texture.format = texDatas.format; + texture.needsUpdate = true; + + if ( onLoad ) onLoad( texture ); + + }, onProgress, onError ); + + } + + return texture; + + }, + + setPath: function ( value ) { + + this.path = value; + return this; + + } + + } ); + + /** + * @author Nikos M. / https://github.com/foo123/ + * + * Abstract Base class to load generic binary textures formats (rgbe, hdr, ...) + */ + + function DataTextureLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + + // override in sub classes + this._parser = null; + + } + + Object.assign( DataTextureLoader.prototype, { + + load: function ( url, onLoad, onProgress, onError ) { + + var scope = this; + + var texture = new DataTexture(); + + var loader = new FileLoader( this.manager ); + loader.setResponseType( 'arraybuffer' ); + + loader.load( url, function ( buffer ) { + + var texData = scope._parser( buffer ); + + if ( ! texData ) return; + + if ( undefined !== texData.image ) { + + texture.image = texData.image; + + } else if ( undefined !== texData.data ) { + + texture.image.width = texData.width; + texture.image.height = texData.height; + texture.image.data = texData.data; + + } + + texture.wrapS = undefined !== texData.wrapS ? texData.wrapS : ClampToEdgeWrapping; + texture.wrapT = undefined !== texData.wrapT ? texData.wrapT : ClampToEdgeWrapping; + + texture.magFilter = undefined !== texData.magFilter ? texData.magFilter : LinearFilter; + texture.minFilter = undefined !== texData.minFilter ? texData.minFilter : LinearMipMapLinearFilter; + + texture.anisotropy = undefined !== texData.anisotropy ? texData.anisotropy : 1; + + if ( undefined !== texData.format ) { + + texture.format = texData.format; + + } + if ( undefined !== texData.type ) { + + texture.type = texData.type; + + } + + if ( undefined !== texData.mipmaps ) { + + texture.mipmaps = texData.mipmaps; + + } + + if ( 1 === texData.mipmapCount ) { + + texture.minFilter = LinearFilter; + + } + + texture.needsUpdate = true; + + if ( onLoad ) onLoad( texture, texData ); + + }, onProgress, onError ); + + + return texture; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + + function ImageLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + + } + + Object.assign( ImageLoader.prototype, { + + crossOrigin: 'Anonymous', + + load: function ( url, onLoad, onProgress, onError ) { + + if ( url === undefined ) url = ''; + + if ( this.path !== undefined ) url = this.path + url; + + url = this.manager.resolveURL( url ); + + var scope = this; + + var cached = Cache.get( url ); + + if ( cached !== undefined ) { + + scope.manager.itemStart( url ); + + setTimeout( function () { + + if ( onLoad ) onLoad( cached ); + + scope.manager.itemEnd( url ); + + }, 0 ); + + return cached; + + } + + var image = document.createElementNS( 'http://www.w3.org/1999/xhtml', 'img' ); + + function onImageLoad() { + + image.removeEventListener( 'load', onImageLoad, false ); + image.removeEventListener( 'error', onImageError, false ); + + Cache.add( url, this ); + + if ( onLoad ) onLoad( this ); + + scope.manager.itemEnd( url ); + + } + + function onImageError( event ) { + + image.removeEventListener( 'load', onImageLoad, false ); + image.removeEventListener( 'error', onImageError, false ); + + if ( onError ) onError( event ); + + scope.manager.itemEnd( url ); + scope.manager.itemError( url ); + + } + + image.addEventListener( 'load', onImageLoad, false ); + image.addEventListener( 'error', onImageError, false ); + + if ( url.substr( 0, 5 ) !== 'data:' ) { + + if ( this.crossOrigin !== undefined ) image.crossOrigin = this.crossOrigin; + + } + + scope.manager.itemStart( url ); + + image.src = url; + + return image; + + }, + + setCrossOrigin: function ( value ) { + + this.crossOrigin = value; + return this; + + }, + + setPath: function ( value ) { + + this.path = value; + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + + function CubeTextureLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + + } + + Object.assign( CubeTextureLoader.prototype, { + + crossOrigin: 'Anonymous', + + load: function ( urls, onLoad, onProgress, onError ) { + + var texture = new CubeTexture(); + + var loader = new ImageLoader( this.manager ); + loader.setCrossOrigin( this.crossOrigin ); + loader.setPath( this.path ); + + var loaded = 0; + + function loadTexture( i ) { + + loader.load( urls[ i ], function ( image ) { + + texture.images[ i ] = image; + + loaded ++; + + if ( loaded === 6 ) { + + texture.needsUpdate = true; + + if ( onLoad ) onLoad( texture ); + + } + + }, undefined, onError ); + + } + + for ( var i = 0; i < urls.length; ++ i ) { + + loadTexture( i ); + + } + + return texture; + + }, + + setCrossOrigin: function ( value ) { + + this.crossOrigin = value; + return this; + + }, + + setPath: function ( value ) { + + this.path = value; + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + + function TextureLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + + } + + Object.assign( TextureLoader.prototype, { + + crossOrigin: 'Anonymous', + + load: function ( url, onLoad, onProgress, onError ) { + + var texture = new Texture(); + + var loader = new ImageLoader( this.manager ); + loader.setCrossOrigin( this.crossOrigin ); + loader.setPath( this.path ); + + loader.load( url, function ( image ) { + + texture.image = image; + + // JPEGs can't have an alpha channel, so memory can be saved by storing them as RGB. + var isJPEG = url.search( /\.(jpg|jpeg)$/ ) > 0 || url.search( /^data\:image\/jpeg/ ) === 0; + + texture.format = isJPEG ? RGBFormat : RGBAFormat; + texture.needsUpdate = true; + + if ( onLoad !== undefined ) { + + onLoad( texture ); + + } + + }, onProgress, onError ); + + return texture; + + }, + + setCrossOrigin: function ( value ) { + + this.crossOrigin = value; + return this; + + }, + + setPath: function ( value ) { + + this.path = value; + return this; + + } + + } ); + + /** + * @author zz85 / http://www.lab4games.net/zz85/blog + * Extensible curve object + * + * Some common of curve methods: + * .getPoint( t, optionalTarget ), .getTangent( t ) + * .getPointAt( u, optionalTarget ), .getTangentAt( u ) + * .getPoints(), .getSpacedPoints() + * .getLength() + * .updateArcLengths() + * + * This following curves inherit from THREE.Curve: + * + * -- 2D curves -- + * THREE.ArcCurve + * THREE.CubicBezierCurve + * THREE.EllipseCurve + * THREE.LineCurve + * THREE.QuadraticBezierCurve + * THREE.SplineCurve + * + * -- 3D curves -- + * THREE.CatmullRomCurve3 + * THREE.CubicBezierCurve3 + * THREE.LineCurve3 + * THREE.QuadraticBezierCurve3 + * + * A series of curves can be represented as a THREE.CurvePath. + * + **/ + + /************************************************************** + * Abstract Curve base class + **************************************************************/ + + function Curve() { + + this.type = 'Curve'; + + this.arcLengthDivisions = 200; + + } + + Object.assign( Curve.prototype, { + + // Virtual base class method to overwrite and implement in subclasses + // - t [0 .. 1] + + getPoint: function ( /* t, optionalTarget */ ) { + + console.warn( 'THREE.Curve: .getPoint() not implemented.' ); + return null; + + }, + + // Get point at relative position in curve according to arc length + // - u [0 .. 1] + + getPointAt: function ( u, optionalTarget ) { + + var t = this.getUtoTmapping( u ); + return this.getPoint( t, optionalTarget ); + + }, + + // Get sequence of points using getPoint( t ) + + getPoints: function ( divisions ) { + + if ( divisions === undefined ) divisions = 5; + + var points = []; + + for ( var d = 0; d <= divisions; d ++ ) { + + points.push( this.getPoint( d / divisions ) ); + + } + + return points; + + }, + + // Get sequence of points using getPointAt( u ) + + getSpacedPoints: function ( divisions ) { + + if ( divisions === undefined ) divisions = 5; + + var points = []; + + for ( var d = 0; d <= divisions; d ++ ) { + + points.push( this.getPointAt( d / divisions ) ); + + } + + return points; + + }, + + // Get total curve arc length + + getLength: function () { + + var lengths = this.getLengths(); + return lengths[ lengths.length - 1 ]; + + }, + + // Get list of cumulative segment lengths + + getLengths: function ( divisions ) { + + if ( divisions === undefined ) divisions = this.arcLengthDivisions; + + if ( this.cacheArcLengths && + ( this.cacheArcLengths.length === divisions + 1 ) && + ! this.needsUpdate ) { + + return this.cacheArcLengths; + + } + + this.needsUpdate = false; + + var cache = []; + var current, last = this.getPoint( 0 ); + var p, sum = 0; + + cache.push( 0 ); + + for ( p = 1; p <= divisions; p ++ ) { + + current = this.getPoint( p / divisions ); + sum += current.distanceTo( last ); + cache.push( sum ); + last = current; + + } + + this.cacheArcLengths = cache; + + return cache; // { sums: cache, sum: sum }; Sum is in the last element. + + }, + + updateArcLengths: function () { + + this.needsUpdate = true; + this.getLengths(); + + }, + + // Given u ( 0 .. 1 ), get a t to find p. This gives you points which are equidistant + + getUtoTmapping: function ( u, distance ) { + + var arcLengths = this.getLengths(); + + var i = 0, il = arcLengths.length; + + var targetArcLength; // The targeted u distance value to get + + if ( distance ) { + + targetArcLength = distance; + + } else { + + targetArcLength = u * arcLengths[ il - 1 ]; + + } + + // binary search for the index with largest value smaller than target u distance + + var low = 0, high = il - 1, comparison; + + while ( low <= high ) { + + i = Math.floor( low + ( high - low ) / 2 ); // less likely to overflow, though probably not issue here, JS doesn't really have integers, all numbers are floats + + comparison = arcLengths[ i ] - targetArcLength; + + if ( comparison < 0 ) { + + low = i + 1; + + } else if ( comparison > 0 ) { + + high = i - 1; + + } else { + + high = i; + break; + + // DONE + + } + + } + + i = high; + + if ( arcLengths[ i ] === targetArcLength ) { + + return i / ( il - 1 ); + + } + + // we could get finer grain at lengths, or use simple interpolation between two points + + var lengthBefore = arcLengths[ i ]; + var lengthAfter = arcLengths[ i + 1 ]; + + var segmentLength = lengthAfter - lengthBefore; + + // determine where we are between the 'before' and 'after' points + + var segmentFraction = ( targetArcLength - lengthBefore ) / segmentLength; + + // add that fractional amount to t + + var t = ( i + segmentFraction ) / ( il - 1 ); + + return t; + + }, + + // Returns a unit vector tangent at t + // In case any sub curve does not implement its tangent derivation, + // 2 points a small delta apart will be used to find its gradient + // which seems to give a reasonable approximation + + getTangent: function ( t ) { + + var delta = 0.0001; + var t1 = t - delta; + var t2 = t + delta; + + // Capping in case of danger + + if ( t1 < 0 ) t1 = 0; + if ( t2 > 1 ) t2 = 1; + + var pt1 = this.getPoint( t1 ); + var pt2 = this.getPoint( t2 ); + + var vec = pt2.clone().sub( pt1 ); + return vec.normalize(); + + }, + + getTangentAt: function ( u ) { + + var t = this.getUtoTmapping( u ); + return this.getTangent( t ); + + }, + + computeFrenetFrames: function ( segments, closed ) { + + // see http://www.cs.indiana.edu/pub/techreports/TR425.pdf + + var normal = new Vector3(); + + var tangents = []; + var normals = []; + var binormals = []; + + var vec = new Vector3(); + var mat = new Matrix4(); + + var i, u, theta; + + // compute the tangent vectors for each segment on the curve + + for ( i = 0; i <= segments; i ++ ) { + + u = i / segments; + + tangents[ i ] = this.getTangentAt( u ); + tangents[ i ].normalize(); + + } + + // select an initial normal vector perpendicular to the first tangent vector, + // and in the direction of the minimum tangent xyz component + + normals[ 0 ] = new Vector3(); + binormals[ 0 ] = new Vector3(); + var min = Number.MAX_VALUE; + var tx = Math.abs( tangents[ 0 ].x ); + var ty = Math.abs( tangents[ 0 ].y ); + var tz = Math.abs( tangents[ 0 ].z ); + + if ( tx <= min ) { + + min = tx; + normal.set( 1, 0, 0 ); + + } + + if ( ty <= min ) { + + min = ty; + normal.set( 0, 1, 0 ); + + } + + if ( tz <= min ) { + + normal.set( 0, 0, 1 ); + + } + + vec.crossVectors( tangents[ 0 ], normal ).normalize(); + + normals[ 0 ].crossVectors( tangents[ 0 ], vec ); + binormals[ 0 ].crossVectors( tangents[ 0 ], normals[ 0 ] ); + + + // compute the slowly-varying normal and binormal vectors for each segment on the curve + + for ( i = 1; i <= segments; i ++ ) { + + normals[ i ] = normals[ i - 1 ].clone(); + + binormals[ i ] = binormals[ i - 1 ].clone(); + + vec.crossVectors( tangents[ i - 1 ], tangents[ i ] ); + + if ( vec.length() > Number.EPSILON ) { + + vec.normalize(); + + theta = Math.acos( _Math.clamp( tangents[ i - 1 ].dot( tangents[ i ] ), - 1, 1 ) ); // clamp for floating pt errors + + normals[ i ].applyMatrix4( mat.makeRotationAxis( vec, theta ) ); + + } + + binormals[ i ].crossVectors( tangents[ i ], normals[ i ] ); + + } + + // if the curve is closed, postprocess the vectors so the first and last normal vectors are the same + + if ( closed === true ) { + + theta = Math.acos( _Math.clamp( normals[ 0 ].dot( normals[ segments ] ), - 1, 1 ) ); + theta /= segments; + + if ( tangents[ 0 ].dot( vec.crossVectors( normals[ 0 ], normals[ segments ] ) ) > 0 ) { + + theta = - theta; + + } + + for ( i = 1; i <= segments; i ++ ) { + + // twist a little... + normals[ i ].applyMatrix4( mat.makeRotationAxis( tangents[ i ], theta * i ) ); + binormals[ i ].crossVectors( tangents[ i ], normals[ i ] ); + + } + + } + + return { + tangents: tangents, + normals: normals, + binormals: binormals + }; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( source ) { + + this.arcLengthDivisions = source.arcLengthDivisions; + + return this; + + }, + + toJSON: function () { + + var data = { + metadata: { + version: 4.5, + type: 'Curve', + generator: 'Curve.toJSON' + } + }; + + data.arcLengthDivisions = this.arcLengthDivisions; + data.type = this.type; + + return data; + + }, + + fromJSON: function ( json ) { + + this.arcLengthDivisions = json.arcLengthDivisions; + + return this; + + } + + } ); + + function EllipseCurve( aX, aY, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise, aRotation ) { + + Curve.call( this ); + + this.type = 'EllipseCurve'; + + this.aX = aX || 0; + this.aY = aY || 0; + + this.xRadius = xRadius || 1; + this.yRadius = yRadius || 1; + + this.aStartAngle = aStartAngle || 0; + this.aEndAngle = aEndAngle || 2 * Math.PI; + + this.aClockwise = aClockwise || false; + + this.aRotation = aRotation || 0; + + } + + EllipseCurve.prototype = Object.create( Curve.prototype ); + EllipseCurve.prototype.constructor = EllipseCurve; + + EllipseCurve.prototype.isEllipseCurve = true; + + EllipseCurve.prototype.getPoint = function ( t, optionalTarget ) { + + var point = optionalTarget || new Vector2(); + + var twoPi = Math.PI * 2; + var deltaAngle = this.aEndAngle - this.aStartAngle; + var samePoints = Math.abs( deltaAngle ) < Number.EPSILON; + + // ensures that deltaAngle is 0 .. 2 PI + while ( deltaAngle < 0 ) deltaAngle += twoPi; + while ( deltaAngle > twoPi ) deltaAngle -= twoPi; + + if ( deltaAngle < Number.EPSILON ) { + + if ( samePoints ) { + + deltaAngle = 0; + + } else { + + deltaAngle = twoPi; + + } + + } + + if ( this.aClockwise === true && ! samePoints ) { + + if ( deltaAngle === twoPi ) { + + deltaAngle = - twoPi; + + } else { + + deltaAngle = deltaAngle - twoPi; + + } + + } + + var angle = this.aStartAngle + t * deltaAngle; + var x = this.aX + this.xRadius * Math.cos( angle ); + var y = this.aY + this.yRadius * Math.sin( angle ); + + if ( this.aRotation !== 0 ) { + + var cos = Math.cos( this.aRotation ); + var sin = Math.sin( this.aRotation ); + + var tx = x - this.aX; + var ty = y - this.aY; + + // Rotate the point about the center of the ellipse. + x = tx * cos - ty * sin + this.aX; + y = tx * sin + ty * cos + this.aY; + + } + + return point.set( x, y ); + + }; + + EllipseCurve.prototype.copy = function ( source ) { + + Curve.prototype.copy.call( this, source ); + + this.aX = source.aX; + this.aY = source.aY; + + this.xRadius = source.xRadius; + this.yRadius = source.yRadius; + + this.aStartAngle = source.aStartAngle; + this.aEndAngle = source.aEndAngle; + + this.aClockwise = source.aClockwise; + + this.aRotation = source.aRotation; + + return this; + + }; + + + EllipseCurve.prototype.toJSON = function () { + + var data = Curve.prototype.toJSON.call( this ); + + data.aX = this.aX; + data.aY = this.aY; + + data.xRadius = this.xRadius; + data.yRadius = this.yRadius; + + data.aStartAngle = this.aStartAngle; + data.aEndAngle = this.aEndAngle; + + data.aClockwise = this.aClockwise; + + data.aRotation = this.aRotation; + + return data; + + }; + + EllipseCurve.prototype.fromJSON = function ( json ) { + + Curve.prototype.fromJSON.call( this, json ); + + this.aX = json.aX; + this.aY = json.aY; + + this.xRadius = json.xRadius; + this.yRadius = json.yRadius; + + this.aStartAngle = json.aStartAngle; + this.aEndAngle = json.aEndAngle; + + this.aClockwise = json.aClockwise; + + this.aRotation = json.aRotation; + + return this; + + }; + + function ArcCurve( aX, aY, aRadius, aStartAngle, aEndAngle, aClockwise ) { + + EllipseCurve.call( this, aX, aY, aRadius, aRadius, aStartAngle, aEndAngle, aClockwise ); + + this.type = 'ArcCurve'; + + } + + ArcCurve.prototype = Object.create( EllipseCurve.prototype ); + ArcCurve.prototype.constructor = ArcCurve; + + ArcCurve.prototype.isArcCurve = true; + + /** + * @author zz85 https://github.com/zz85 + * + * Centripetal CatmullRom Curve - which is useful for avoiding + * cusps and self-intersections in non-uniform catmull rom curves. + * http://www.cemyuksel.com/research/catmullrom_param/catmullrom.pdf + * + * curve.type accepts centripetal(default), chordal and catmullrom + * curve.tension is used for catmullrom which defaults to 0.5 + */ + + + /* + Based on an optimized c++ solution in + - http://stackoverflow.com/questions/9489736/catmull-rom-curve-with-no-cusps-and-no-self-intersections/ + - http://ideone.com/NoEbVM + + This CubicPoly class could be used for reusing some variables and calculations, + but for three.js curve use, it could be possible inlined and flatten into a single function call + which can be placed in CurveUtils. + */ + + function CubicPoly() { + + var c0 = 0, c1 = 0, c2 = 0, c3 = 0; + + /* + * Compute coefficients for a cubic polynomial + * p(s) = c0 + c1*s + c2*s^2 + c3*s^3 + * such that + * p(0) = x0, p(1) = x1 + * and + * p'(0) = t0, p'(1) = t1. + */ + function init( x0, x1, t0, t1 ) { + + c0 = x0; + c1 = t0; + c2 = - 3 * x0 + 3 * x1 - 2 * t0 - t1; + c3 = 2 * x0 - 2 * x1 + t0 + t1; + + } + + return { + + initCatmullRom: function ( x0, x1, x2, x3, tension ) { + + init( x1, x2, tension * ( x2 - x0 ), tension * ( x3 - x1 ) ); + + }, + + initNonuniformCatmullRom: function ( x0, x1, x2, x3, dt0, dt1, dt2 ) { + + // compute tangents when parameterized in [t1,t2] + var t1 = ( x1 - x0 ) / dt0 - ( x2 - x0 ) / ( dt0 + dt1 ) + ( x2 - x1 ) / dt1; + var t2 = ( x2 - x1 ) / dt1 - ( x3 - x1 ) / ( dt1 + dt2 ) + ( x3 - x2 ) / dt2; + + // rescale tangents for parametrization in [0,1] + t1 *= dt1; + t2 *= dt1; + + init( x1, x2, t1, t2 ); + + }, + + calc: function ( t ) { + + var t2 = t * t; + var t3 = t2 * t; + return c0 + c1 * t + c2 * t2 + c3 * t3; + + } + + }; + + } + + // + + var tmp = new Vector3(); + var px = new CubicPoly(), py = new CubicPoly(), pz = new CubicPoly(); + + function CatmullRomCurve3( points, closed, curveType, tension ) { + + Curve.call( this ); + + this.type = 'CatmullRomCurve3'; + + this.points = points || []; + this.closed = closed || false; + this.curveType = curveType || 'centripetal'; + this.tension = tension || 0.5; + + } + + CatmullRomCurve3.prototype = Object.create( Curve.prototype ); + CatmullRomCurve3.prototype.constructor = CatmullRomCurve3; + + CatmullRomCurve3.prototype.isCatmullRomCurve3 = true; + + CatmullRomCurve3.prototype.getPoint = function ( t, optionalTarget ) { + + var point = optionalTarget || new Vector3(); + + var points = this.points; + var l = points.length; + + var p = ( l - ( this.closed ? 0 : 1 ) ) * t; + var intPoint = Math.floor( p ); + var weight = p - intPoint; + + if ( this.closed ) { + + intPoint += intPoint > 0 ? 0 : ( Math.floor( Math.abs( intPoint ) / l ) + 1 ) * l; + + } else if ( weight === 0 && intPoint === l - 1 ) { + + intPoint = l - 2; + weight = 1; + + } + + var p0, p1, p2, p3; // 4 points + + if ( this.closed || intPoint > 0 ) { + + p0 = points[ ( intPoint - 1 ) % l ]; + + } else { + + // extrapolate first point + tmp.subVectors( points[ 0 ], points[ 1 ] ).add( points[ 0 ] ); + p0 = tmp; + + } + + p1 = points[ intPoint % l ]; + p2 = points[ ( intPoint + 1 ) % l ]; + + if ( this.closed || intPoint + 2 < l ) { + + p3 = points[ ( intPoint + 2 ) % l ]; + + } else { + + // extrapolate last point + tmp.subVectors( points[ l - 1 ], points[ l - 2 ] ).add( points[ l - 1 ] ); + p3 = tmp; + + } + + if ( this.curveType === 'centripetal' || this.curveType === 'chordal' ) { + + // init Centripetal / Chordal Catmull-Rom + var pow = this.curveType === 'chordal' ? 0.5 : 0.25; + var dt0 = Math.pow( p0.distanceToSquared( p1 ), pow ); + var dt1 = Math.pow( p1.distanceToSquared( p2 ), pow ); + var dt2 = Math.pow( p2.distanceToSquared( p3 ), pow ); + + // safety check for repeated points + if ( dt1 < 1e-4 ) dt1 = 1.0; + if ( dt0 < 1e-4 ) dt0 = dt1; + if ( dt2 < 1e-4 ) dt2 = dt1; + + px.initNonuniformCatmullRom( p0.x, p1.x, p2.x, p3.x, dt0, dt1, dt2 ); + py.initNonuniformCatmullRom( p0.y, p1.y, p2.y, p3.y, dt0, dt1, dt2 ); + pz.initNonuniformCatmullRom( p0.z, p1.z, p2.z, p3.z, dt0, dt1, dt2 ); + + } else if ( this.curveType === 'catmullrom' ) { + + px.initCatmullRom( p0.x, p1.x, p2.x, p3.x, this.tension ); + py.initCatmullRom( p0.y, p1.y, p2.y, p3.y, this.tension ); + pz.initCatmullRom( p0.z, p1.z, p2.z, p3.z, this.tension ); + + } + + point.set( + px.calc( weight ), + py.calc( weight ), + pz.calc( weight ) + ); + + return point; + + }; + + CatmullRomCurve3.prototype.copy = function ( source ) { + + Curve.prototype.copy.call( this, source ); + + this.points = []; + + for ( var i = 0, l = source.points.length; i < l; i ++ ) { + + var point = source.points[ i ]; + + this.points.push( point.clone() ); + + } + + this.closed = source.closed; + this.curveType = source.curveType; + this.tension = source.tension; + + return this; + + }; + + CatmullRomCurve3.prototype.toJSON = function () { + + var data = Curve.prototype.toJSON.call( this ); + + data.points = []; + + for ( var i = 0, l = this.points.length; i < l; i ++ ) { + + var point = this.points[ i ]; + data.points.push( point.toArray() ); + + } + + data.closed = this.closed; + data.curveType = this.curveType; + data.tension = this.tension; + + return data; + + }; + + CatmullRomCurve3.prototype.fromJSON = function ( json ) { + + Curve.prototype.fromJSON.call( this, json ); + + this.points = []; + + for ( var i = 0, l = json.points.length; i < l; i ++ ) { + + var point = json.points[ i ]; + this.points.push( new Vector3().fromArray( point ) ); + + } + + this.closed = json.closed; + this.curveType = json.curveType; + this.tension = json.tension; + + return this; + + }; + + /** + * @author zz85 / http://www.lab4games.net/zz85/blog + * + * Bezier Curves formulas obtained from + * http://en.wikipedia.org/wiki/Bézier_curve + */ + + function CatmullRom( t, p0, p1, p2, p3 ) { + + var v0 = ( p2 - p0 ) * 0.5; + var v1 = ( p3 - p1 ) * 0.5; + var t2 = t * t; + var t3 = t * t2; + return ( 2 * p1 - 2 * p2 + v0 + v1 ) * t3 + ( - 3 * p1 + 3 * p2 - 2 * v0 - v1 ) * t2 + v0 * t + p1; + + } + + // + + function QuadraticBezierP0( t, p ) { + + var k = 1 - t; + return k * k * p; + + } + + function QuadraticBezierP1( t, p ) { + + return 2 * ( 1 - t ) * t * p; + + } + + function QuadraticBezierP2( t, p ) { + + return t * t * p; + + } + + function QuadraticBezier( t, p0, p1, p2 ) { + + return QuadraticBezierP0( t, p0 ) + QuadraticBezierP1( t, p1 ) + + QuadraticBezierP2( t, p2 ); + + } + + // + + function CubicBezierP0( t, p ) { + + var k = 1 - t; + return k * k * k * p; + + } + + function CubicBezierP1( t, p ) { + + var k = 1 - t; + return 3 * k * k * t * p; + + } + + function CubicBezierP2( t, p ) { + + return 3 * ( 1 - t ) * t * t * p; + + } + + function CubicBezierP3( t, p ) { + + return t * t * t * p; + + } + + function CubicBezier( t, p0, p1, p2, p3 ) { + + return CubicBezierP0( t, p0 ) + CubicBezierP1( t, p1 ) + CubicBezierP2( t, p2 ) + + CubicBezierP3( t, p3 ); + + } + + function CubicBezierCurve( v0, v1, v2, v3 ) { + + Curve.call( this ); + + this.type = 'CubicBezierCurve'; + + this.v0 = v0 || new Vector2(); + this.v1 = v1 || new Vector2(); + this.v2 = v2 || new Vector2(); + this.v3 = v3 || new Vector2(); + + } + + CubicBezierCurve.prototype = Object.create( Curve.prototype ); + CubicBezierCurve.prototype.constructor = CubicBezierCurve; + + CubicBezierCurve.prototype.isCubicBezierCurve = true; + + CubicBezierCurve.prototype.getPoint = function ( t, optionalTarget ) { + + var point = optionalTarget || new Vector2(); + + var v0 = this.v0, v1 = this.v1, v2 = this.v2, v3 = this.v3; + + point.set( + CubicBezier( t, v0.x, v1.x, v2.x, v3.x ), + CubicBezier( t, v0.y, v1.y, v2.y, v3.y ) + ); + + return point; + + }; + + CubicBezierCurve.prototype.copy = function ( source ) { + + Curve.prototype.copy.call( this, source ); + + this.v0.copy( source.v0 ); + this.v1.copy( source.v1 ); + this.v2.copy( source.v2 ); + this.v3.copy( source.v3 ); + + return this; + + }; + + CubicBezierCurve.prototype.toJSON = function () { + + var data = Curve.prototype.toJSON.call( this ); + + data.v0 = this.v0.toArray(); + data.v1 = this.v1.toArray(); + data.v2 = this.v2.toArray(); + data.v3 = this.v3.toArray(); + + return data; + + }; + + CubicBezierCurve.prototype.fromJSON = function ( json ) { + + Curve.prototype.fromJSON.call( this, json ); + + this.v0.fromArray( json.v0 ); + this.v1.fromArray( json.v1 ); + this.v2.fromArray( json.v2 ); + this.v3.fromArray( json.v3 ); + + return this; + + }; + + function CubicBezierCurve3( v0, v1, v2, v3 ) { + + Curve.call( this ); + + this.type = 'CubicBezierCurve3'; + + this.v0 = v0 || new Vector3(); + this.v1 = v1 || new Vector3(); + this.v2 = v2 || new Vector3(); + this.v3 = v3 || new Vector3(); + + } + + CubicBezierCurve3.prototype = Object.create( Curve.prototype ); + CubicBezierCurve3.prototype.constructor = CubicBezierCurve3; + + CubicBezierCurve3.prototype.isCubicBezierCurve3 = true; + + CubicBezierCurve3.prototype.getPoint = function ( t, optionalTarget ) { + + var point = optionalTarget || new Vector3(); + + var v0 = this.v0, v1 = this.v1, v2 = this.v2, v3 = this.v3; + + point.set( + CubicBezier( t, v0.x, v1.x, v2.x, v3.x ), + CubicBezier( t, v0.y, v1.y, v2.y, v3.y ), + CubicBezier( t, v0.z, v1.z, v2.z, v3.z ) + ); + + return point; + + }; + + CubicBezierCurve3.prototype.copy = function ( source ) { + + Curve.prototype.copy.call( this, source ); + + this.v0.copy( source.v0 ); + this.v1.copy( source.v1 ); + this.v2.copy( source.v2 ); + this.v3.copy( source.v3 ); + + return this; + + }; + + CubicBezierCurve3.prototype.toJSON = function () { + + var data = Curve.prototype.toJSON.call( this ); + + data.v0 = this.v0.toArray(); + data.v1 = this.v1.toArray(); + data.v2 = this.v2.toArray(); + data.v3 = this.v3.toArray(); + + return data; + + }; + + CubicBezierCurve3.prototype.fromJSON = function ( json ) { + + Curve.prototype.fromJSON.call( this, json ); + + this.v0.fromArray( json.v0 ); + this.v1.fromArray( json.v1 ); + this.v2.fromArray( json.v2 ); + this.v3.fromArray( json.v3 ); + + return this; + + }; + + function LineCurve( v1, v2 ) { + + Curve.call( this ); + + this.type = 'LineCurve'; + + this.v1 = v1 || new Vector2(); + this.v2 = v2 || new Vector2(); + + } + + LineCurve.prototype = Object.create( Curve.prototype ); + LineCurve.prototype.constructor = LineCurve; + + LineCurve.prototype.isLineCurve = true; + + LineCurve.prototype.getPoint = function ( t, optionalTarget ) { + + var point = optionalTarget || new Vector2(); + + if ( t === 1 ) { + + point.copy( this.v2 ); + + } else { + + point.copy( this.v2 ).sub( this.v1 ); + point.multiplyScalar( t ).add( this.v1 ); + + } + + return point; + + }; + + // Line curve is linear, so we can overwrite default getPointAt + + LineCurve.prototype.getPointAt = function ( u, optionalTarget ) { + + return this.getPoint( u, optionalTarget ); + + }; + + LineCurve.prototype.getTangent = function ( /* t */ ) { + + var tangent = this.v2.clone().sub( this.v1 ); + + return tangent.normalize(); + + }; + + LineCurve.prototype.copy = function ( source ) { + + Curve.prototype.copy.call( this, source ); + + this.v1.copy( source.v1 ); + this.v2.copy( source.v2 ); + + return this; + + }; + + LineCurve.prototype.toJSON = function () { + + var data = Curve.prototype.toJSON.call( this ); + + data.v1 = this.v1.toArray(); + data.v2 = this.v2.toArray(); + + return data; + + }; + + LineCurve.prototype.fromJSON = function ( json ) { + + Curve.prototype.fromJSON.call( this, json ); + + this.v1.fromArray( json.v1 ); + this.v2.fromArray( json.v2 ); + + return this; + + }; + + function LineCurve3( v1, v2 ) { + + Curve.call( this ); + + this.type = 'LineCurve3'; + + this.v1 = v1 || new Vector3(); + this.v2 = v2 || new Vector3(); + + } + + LineCurve3.prototype = Object.create( Curve.prototype ); + LineCurve3.prototype.constructor = LineCurve3; + + LineCurve3.prototype.isLineCurve3 = true; + + LineCurve3.prototype.getPoint = function ( t, optionalTarget ) { + + var point = optionalTarget || new Vector3(); + + if ( t === 1 ) { + + point.copy( this.v2 ); + + } else { + + point.copy( this.v2 ).sub( this.v1 ); + point.multiplyScalar( t ).add( this.v1 ); + + } + + return point; + + }; + + // Line curve is linear, so we can overwrite default getPointAt + + LineCurve3.prototype.getPointAt = function ( u, optionalTarget ) { + + return this.getPoint( u, optionalTarget ); + + }; + + LineCurve3.prototype.copy = function ( source ) { + + Curve.prototype.copy.call( this, source ); + + this.v1.copy( source.v1 ); + this.v2.copy( source.v2 ); + + return this; + + }; + + LineCurve3.prototype.toJSON = function () { + + var data = Curve.prototype.toJSON.call( this ); + + data.v1 = this.v1.toArray(); + data.v2 = this.v2.toArray(); + + return data; + + }; + + LineCurve3.prototype.fromJSON = function ( json ) { + + Curve.prototype.fromJSON.call( this, json ); + + this.v1.fromArray( json.v1 ); + this.v2.fromArray( json.v2 ); + + return this; + + }; + + function QuadraticBezierCurve( v0, v1, v2 ) { + + Curve.call( this ); + + this.type = 'QuadraticBezierCurve'; + + this.v0 = v0 || new Vector2(); + this.v1 = v1 || new Vector2(); + this.v2 = v2 || new Vector2(); + + } + + QuadraticBezierCurve.prototype = Object.create( Curve.prototype ); + QuadraticBezierCurve.prototype.constructor = QuadraticBezierCurve; + + QuadraticBezierCurve.prototype.isQuadraticBezierCurve = true; + + QuadraticBezierCurve.prototype.getPoint = function ( t, optionalTarget ) { + + var point = optionalTarget || new Vector2(); + + var v0 = this.v0, v1 = this.v1, v2 = this.v2; + + point.set( + QuadraticBezier( t, v0.x, v1.x, v2.x ), + QuadraticBezier( t, v0.y, v1.y, v2.y ) + ); + + return point; + + }; + + QuadraticBezierCurve.prototype.copy = function ( source ) { + + Curve.prototype.copy.call( this, source ); + + this.v0.copy( source.v0 ); + this.v1.copy( source.v1 ); + this.v2.copy( source.v2 ); + + return this; + + }; + + QuadraticBezierCurve.prototype.toJSON = function () { + + var data = Curve.prototype.toJSON.call( this ); + + data.v0 = this.v0.toArray(); + data.v1 = this.v1.toArray(); + data.v2 = this.v2.toArray(); + + return data; + + }; + + QuadraticBezierCurve.prototype.fromJSON = function ( json ) { + + Curve.prototype.fromJSON.call( this, json ); + + this.v0.fromArray( json.v0 ); + this.v1.fromArray( json.v1 ); + this.v2.fromArray( json.v2 ); + + return this; + + }; + + function QuadraticBezierCurve3( v0, v1, v2 ) { + + Curve.call( this ); + + this.type = 'QuadraticBezierCurve3'; + + this.v0 = v0 || new Vector3(); + this.v1 = v1 || new Vector3(); + this.v2 = v2 || new Vector3(); + + } + + QuadraticBezierCurve3.prototype = Object.create( Curve.prototype ); + QuadraticBezierCurve3.prototype.constructor = QuadraticBezierCurve3; + + QuadraticBezierCurve3.prototype.isQuadraticBezierCurve3 = true; + + QuadraticBezierCurve3.prototype.getPoint = function ( t, optionalTarget ) { + + var point = optionalTarget || new Vector3(); + + var v0 = this.v0, v1 = this.v1, v2 = this.v2; + + point.set( + QuadraticBezier( t, v0.x, v1.x, v2.x ), + QuadraticBezier( t, v0.y, v1.y, v2.y ), + QuadraticBezier( t, v0.z, v1.z, v2.z ) + ); + + return point; + + }; + + QuadraticBezierCurve3.prototype.copy = function ( source ) { + + Curve.prototype.copy.call( this, source ); + + this.v0.copy( source.v0 ); + this.v1.copy( source.v1 ); + this.v2.copy( source.v2 ); + + return this; + + }; + + QuadraticBezierCurve3.prototype.toJSON = function () { + + var data = Curve.prototype.toJSON.call( this ); + + data.v0 = this.v0.toArray(); + data.v1 = this.v1.toArray(); + data.v2 = this.v2.toArray(); + + return data; + + }; + + QuadraticBezierCurve3.prototype.fromJSON = function ( json ) { + + Curve.prototype.fromJSON.call( this, json ); + + this.v0.fromArray( json.v0 ); + this.v1.fromArray( json.v1 ); + this.v2.fromArray( json.v2 ); + + return this; + + }; + + function SplineCurve( points /* array of Vector2 */ ) { + + Curve.call( this ); + + this.type = 'SplineCurve'; + + this.points = points || []; + + } + + SplineCurve.prototype = Object.create( Curve.prototype ); + SplineCurve.prototype.constructor = SplineCurve; + + SplineCurve.prototype.isSplineCurve = true; + + SplineCurve.prototype.getPoint = function ( t, optionalTarget ) { + + var point = optionalTarget || new Vector2(); + + var points = this.points; + var p = ( points.length - 1 ) * t; + + var intPoint = Math.floor( p ); + var weight = p - intPoint; + + var p0 = points[ intPoint === 0 ? intPoint : intPoint - 1 ]; + var p1 = points[ intPoint ]; + var p2 = points[ intPoint > points.length - 2 ? points.length - 1 : intPoint + 1 ]; + var p3 = points[ intPoint > points.length - 3 ? points.length - 1 : intPoint + 2 ]; + + point.set( + CatmullRom( weight, p0.x, p1.x, p2.x, p3.x ), + CatmullRom( weight, p0.y, p1.y, p2.y, p3.y ) + ); + + return point; + + }; + + SplineCurve.prototype.copy = function ( source ) { + + Curve.prototype.copy.call( this, source ); + + this.points = []; + + for ( var i = 0, l = source.points.length; i < l; i ++ ) { + + var point = source.points[ i ]; + + this.points.push( point.clone() ); + + } + + return this; + + }; + + SplineCurve.prototype.toJSON = function () { + + var data = Curve.prototype.toJSON.call( this ); + + data.points = []; + + for ( var i = 0, l = this.points.length; i < l; i ++ ) { + + var point = this.points[ i ]; + data.points.push( point.toArray() ); + + } + + return data; + + }; + + SplineCurve.prototype.fromJSON = function ( json ) { + + Curve.prototype.fromJSON.call( this, json ); + + this.points = []; + + for ( var i = 0, l = json.points.length; i < l; i ++ ) { + + var point = json.points[ i ]; + this.points.push( new Vector2().fromArray( point ) ); + + } + + return this; + + }; + + + + var Curves = /*#__PURE__*/Object.freeze({ + ArcCurve: ArcCurve, + CatmullRomCurve3: CatmullRomCurve3, + CubicBezierCurve: CubicBezierCurve, + CubicBezierCurve3: CubicBezierCurve3, + EllipseCurve: EllipseCurve, + LineCurve: LineCurve, + LineCurve3: LineCurve3, + QuadraticBezierCurve: QuadraticBezierCurve, + QuadraticBezierCurve3: QuadraticBezierCurve3, + SplineCurve: SplineCurve + }); + + /** + * @author zz85 / http://www.lab4games.net/zz85/blog + * + **/ + + /************************************************************** + * Curved Path - a curve path is simply a array of connected + * curves, but retains the api of a curve + **************************************************************/ + + function CurvePath() { + + Curve.call( this ); + + this.type = 'CurvePath'; + + this.curves = []; + this.autoClose = false; // Automatically closes the path + + } + + CurvePath.prototype = Object.assign( Object.create( Curve.prototype ), { + + constructor: CurvePath, + + add: function ( curve ) { + + this.curves.push( curve ); + + }, + + closePath: function () { + + // Add a line curve if start and end of lines are not connected + var startPoint = this.curves[ 0 ].getPoint( 0 ); + var endPoint = this.curves[ this.curves.length - 1 ].getPoint( 1 ); + + if ( ! startPoint.equals( endPoint ) ) { + + this.curves.push( new LineCurve( endPoint, startPoint ) ); + + } + + }, + + // To get accurate point with reference to + // entire path distance at time t, + // following has to be done: + + // 1. Length of each sub path have to be known + // 2. Locate and identify type of curve + // 3. Get t for the curve + // 4. Return curve.getPointAt(t') + + getPoint: function ( t ) { + + var d = t * this.getLength(); + var curveLengths = this.getCurveLengths(); + var i = 0; + + // To think about boundaries points. + + while ( i < curveLengths.length ) { + + if ( curveLengths[ i ] >= d ) { + + var diff = curveLengths[ i ] - d; + var curve = this.curves[ i ]; + + var segmentLength = curve.getLength(); + var u = segmentLength === 0 ? 0 : 1 - diff / segmentLength; + + return curve.getPointAt( u ); + + } + + i ++; + + } + + return null; + + // loop where sum != 0, sum > d , sum+1 1 && ! points[ points.length - 1 ].equals( points[ 0 ] ) ) { + + points.push( points[ 0 ] ); + + } + + return points; + + }, + + copy: function ( source ) { + + Curve.prototype.copy.call( this, source ); + + this.curves = []; + + for ( var i = 0, l = source.curves.length; i < l; i ++ ) { + + var curve = source.curves[ i ]; + + this.curves.push( curve.clone() ); + + } + + this.autoClose = source.autoClose; + + return this; + + }, + + toJSON: function () { + + var data = Curve.prototype.toJSON.call( this ); + + data.autoClose = this.autoClose; + data.curves = []; + + for ( var i = 0, l = this.curves.length; i < l; i ++ ) { + + var curve = this.curves[ i ]; + data.curves.push( curve.toJSON() ); + + } + + return data; + + }, + + fromJSON: function ( json ) { + + Curve.prototype.fromJSON.call( this, json ); + + this.autoClose = json.autoClose; + this.curves = []; + + for ( var i = 0, l = json.curves.length; i < l; i ++ ) { + + var curve = json.curves[ i ]; + this.curves.push( new Curves[ curve.type ]().fromJSON( curve ) ); + + } + + return this; + + } + + } ); + + /** + * @author zz85 / http://www.lab4games.net/zz85/blog + * Creates free form 2d path using series of points, lines or curves. + **/ + + function Path( points ) { + + CurvePath.call( this ); + + this.type = 'Path'; + + this.currentPoint = new Vector2(); + + if ( points ) { + + this.setFromPoints( points ); + + } + + } + + Path.prototype = Object.assign( Object.create( CurvePath.prototype ), { + + constructor: Path, + + setFromPoints: function ( points ) { + + this.moveTo( points[ 0 ].x, points[ 0 ].y ); + + for ( var i = 1, l = points.length; i < l; i ++ ) { + + this.lineTo( points[ i ].x, points[ i ].y ); + + } + + }, + + moveTo: function ( x, y ) { + + this.currentPoint.set( x, y ); // TODO consider referencing vectors instead of copying? + + }, + + lineTo: function ( x, y ) { + + var curve = new LineCurve( this.currentPoint.clone(), new Vector2( x, y ) ); + this.curves.push( curve ); + + this.currentPoint.set( x, y ); + + }, + + quadraticCurveTo: function ( aCPx, aCPy, aX, aY ) { + + var curve = new QuadraticBezierCurve( + this.currentPoint.clone(), + new Vector2( aCPx, aCPy ), + new Vector2( aX, aY ) + ); + + this.curves.push( curve ); + + this.currentPoint.set( aX, aY ); + + }, + + bezierCurveTo: function ( aCP1x, aCP1y, aCP2x, aCP2y, aX, aY ) { + + var curve = new CubicBezierCurve( + this.currentPoint.clone(), + new Vector2( aCP1x, aCP1y ), + new Vector2( aCP2x, aCP2y ), + new Vector2( aX, aY ) + ); + + this.curves.push( curve ); + + this.currentPoint.set( aX, aY ); + + }, + + splineThru: function ( pts /*Array of Vector*/ ) { + + var npts = [ this.currentPoint.clone() ].concat( pts ); + + var curve = new SplineCurve( npts ); + this.curves.push( curve ); + + this.currentPoint.copy( pts[ pts.length - 1 ] ); + + }, + + arc: function ( aX, aY, aRadius, aStartAngle, aEndAngle, aClockwise ) { + + var x0 = this.currentPoint.x; + var y0 = this.currentPoint.y; + + this.absarc( aX + x0, aY + y0, aRadius, + aStartAngle, aEndAngle, aClockwise ); + + }, + + absarc: function ( aX, aY, aRadius, aStartAngle, aEndAngle, aClockwise ) { + + this.absellipse( aX, aY, aRadius, aRadius, aStartAngle, aEndAngle, aClockwise ); + + }, + + ellipse: function ( aX, aY, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise, aRotation ) { + + var x0 = this.currentPoint.x; + var y0 = this.currentPoint.y; + + this.absellipse( aX + x0, aY + y0, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise, aRotation ); + + }, + + absellipse: function ( aX, aY, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise, aRotation ) { + + var curve = new EllipseCurve( aX, aY, xRadius, yRadius, aStartAngle, aEndAngle, aClockwise, aRotation ); + + if ( this.curves.length > 0 ) { + + // if a previous curve is present, attempt to join + var firstPoint = curve.getPoint( 0 ); + + if ( ! firstPoint.equals( this.currentPoint ) ) { + + this.lineTo( firstPoint.x, firstPoint.y ); + + } + + } + + this.curves.push( curve ); + + var lastPoint = curve.getPoint( 1 ); + this.currentPoint.copy( lastPoint ); + + }, + + copy: function ( source ) { + + CurvePath.prototype.copy.call( this, source ); + + this.currentPoint.copy( source.currentPoint ); + + return this; + + }, + + toJSON: function () { + + var data = CurvePath.prototype.toJSON.call( this ); + + data.currentPoint = this.currentPoint.toArray(); + + return data; + + }, + + fromJSON: function ( json ) { + + CurvePath.prototype.fromJSON.call( this, json ); + + this.currentPoint.fromArray( json.currentPoint ); + + return this; + + } + + } ); + + /** + * @author zz85 / http://www.lab4games.net/zz85/blog + * Defines a 2d shape plane using paths. + **/ + + // STEP 1 Create a path. + // STEP 2 Turn path into shape. + // STEP 3 ExtrudeGeometry takes in Shape/Shapes + // STEP 3a - Extract points from each shape, turn to vertices + // STEP 3b - Triangulate each shape, add faces. + + function Shape( points ) { + + Path.call( this, points ); + + this.uuid = _Math.generateUUID(); + + this.type = 'Shape'; + + this.holes = []; + + } + + Shape.prototype = Object.assign( Object.create( Path.prototype ), { + + constructor: Shape, + + getPointsHoles: function ( divisions ) { + + var holesPts = []; + + for ( var i = 0, l = this.holes.length; i < l; i ++ ) { + + holesPts[ i ] = this.holes[ i ].getPoints( divisions ); + + } + + return holesPts; + + }, + + // get points of shape and holes (keypoints based on segments parameter) + + extractPoints: function ( divisions ) { + + return { + + shape: this.getPoints( divisions ), + holes: this.getPointsHoles( divisions ) + + }; + + }, + + copy: function ( source ) { + + Path.prototype.copy.call( this, source ); + + this.holes = []; + + for ( var i = 0, l = source.holes.length; i < l; i ++ ) { + + var hole = source.holes[ i ]; + + this.holes.push( hole.clone() ); + + } + + return this; + + }, + + toJSON: function () { + + var data = Path.prototype.toJSON.call( this ); + + data.uuid = this.uuid; + data.holes = []; + + for ( var i = 0, l = this.holes.length; i < l; i ++ ) { + + var hole = this.holes[ i ]; + data.holes.push( hole.toJSON() ); + + } + + return data; + + }, + + fromJSON: function ( json ) { + + Path.prototype.fromJSON.call( this, json ); + + this.uuid = json.uuid; + this.holes = []; + + for ( var i = 0, l = json.holes.length; i < l; i ++ ) { + + var hole = json.holes[ i ]; + this.holes.push( new Path().fromJSON( hole ) ); + + } + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + */ + + function Light( color, intensity ) { + + Object3D.call( this ); + + this.type = 'Light'; + + this.color = new Color( color ); + this.intensity = intensity !== undefined ? intensity : 1; + + this.receiveShadow = undefined; + + } + + Light.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: Light, + + isLight: true, + + copy: function ( source ) { + + Object3D.prototype.copy.call( this, source ); + + this.color.copy( source.color ); + this.intensity = source.intensity; + + return this; + + }, + + toJSON: function ( meta ) { + + var data = Object3D.prototype.toJSON.call( this, meta ); + + data.object.color = this.color.getHex(); + data.object.intensity = this.intensity; + + if ( this.groundColor !== undefined ) data.object.groundColor = this.groundColor.getHex(); + + if ( this.distance !== undefined ) data.object.distance = this.distance; + if ( this.angle !== undefined ) data.object.angle = this.angle; + if ( this.decay !== undefined ) data.object.decay = this.decay; + if ( this.penumbra !== undefined ) data.object.penumbra = this.penumbra; + + if ( this.shadow !== undefined ) data.object.shadow = this.shadow.toJSON(); + + return data; + + } + + } ); + + /** + * @author alteredq / http://alteredqualia.com/ + */ + + function HemisphereLight( skyColor, groundColor, intensity ) { + + Light.call( this, skyColor, intensity ); + + this.type = 'HemisphereLight'; + + this.castShadow = undefined; + + this.position.copy( Object3D.DefaultUp ); + this.updateMatrix(); + + this.groundColor = new Color( groundColor ); + + } + + HemisphereLight.prototype = Object.assign( Object.create( Light.prototype ), { + + constructor: HemisphereLight, + + isHemisphereLight: true, + + copy: function ( source ) { + + Light.prototype.copy.call( this, source ); + + this.groundColor.copy( source.groundColor ); + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function LightShadow( camera ) { + + this.camera = camera; + + this.bias = 0; + this.radius = 1; + + this.mapSize = new Vector2( 512, 512 ); + + this.map = null; + this.matrix = new Matrix4(); + + } + + Object.assign( LightShadow.prototype, { + + copy: function ( source ) { + + this.camera = source.camera.clone(); + + this.bias = source.bias; + this.radius = source.radius; + + this.mapSize.copy( source.mapSize ); + + return this; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + toJSON: function () { + + var object = {}; + + if ( this.bias !== 0 ) object.bias = this.bias; + if ( this.radius !== 1 ) object.radius = this.radius; + if ( this.mapSize.x !== 512 || this.mapSize.y !== 512 ) object.mapSize = this.mapSize.toArray(); + + object.camera = this.camera.toJSON( false ).object; + delete object.camera.matrix; + + return object; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function SpotLightShadow() { + + LightShadow.call( this, new PerspectiveCamera( 50, 1, 0.5, 500 ) ); + + } + + SpotLightShadow.prototype = Object.assign( Object.create( LightShadow.prototype ), { + + constructor: SpotLightShadow, + + isSpotLightShadow: true, + + update: function ( light ) { + + var camera = this.camera; + + var fov = _Math.RAD2DEG * 2 * light.angle; + var aspect = this.mapSize.width / this.mapSize.height; + var far = light.distance || camera.far; + + if ( fov !== camera.fov || aspect !== camera.aspect || far !== camera.far ) { + + camera.fov = fov; + camera.aspect = aspect; + camera.far = far; + camera.updateProjectionMatrix(); + + } + + } + + } ); + + /** + * @author alteredq / http://alteredqualia.com/ + */ + + function SpotLight( color, intensity, distance, angle, penumbra, decay ) { + + Light.call( this, color, intensity ); + + this.type = 'SpotLight'; + + this.position.copy( Object3D.DefaultUp ); + this.updateMatrix(); + + this.target = new Object3D(); + + Object.defineProperty( this, 'power', { + get: function () { + + // intensity = power per solid angle. + // ref: equation (17) from https://seblagarde.files.wordpress.com/2015/07/course_notes_moving_frostbite_to_pbr_v32.pdf + return this.intensity * Math.PI; + + }, + set: function ( power ) { + + // intensity = power per solid angle. + // ref: equation (17) from https://seblagarde.files.wordpress.com/2015/07/course_notes_moving_frostbite_to_pbr_v32.pdf + this.intensity = power / Math.PI; + + } + } ); + + this.distance = ( distance !== undefined ) ? distance : 0; + this.angle = ( angle !== undefined ) ? angle : Math.PI / 3; + this.penumbra = ( penumbra !== undefined ) ? penumbra : 0; + this.decay = ( decay !== undefined ) ? decay : 1; // for physically correct lights, should be 2. + + this.shadow = new SpotLightShadow(); + + } + + SpotLight.prototype = Object.assign( Object.create( Light.prototype ), { + + constructor: SpotLight, + + isSpotLight: true, + + copy: function ( source ) { + + Light.prototype.copy.call( this, source ); + + this.distance = source.distance; + this.angle = source.angle; + this.penumbra = source.penumbra; + this.decay = source.decay; + + this.target = source.target.clone(); + + this.shadow = source.shadow.clone(); + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + + function PointLight( color, intensity, distance, decay ) { + + Light.call( this, color, intensity ); + + this.type = 'PointLight'; + + Object.defineProperty( this, 'power', { + get: function () { + + // intensity = power per solid angle. + // ref: equation (15) from https://seblagarde.files.wordpress.com/2015/07/course_notes_moving_frostbite_to_pbr_v32.pdf + return this.intensity * 4 * Math.PI; + + }, + set: function ( power ) { + + // intensity = power per solid angle. + // ref: equation (15) from https://seblagarde.files.wordpress.com/2015/07/course_notes_moving_frostbite_to_pbr_v32.pdf + this.intensity = power / ( 4 * Math.PI ); + + } + } ); + + this.distance = ( distance !== undefined ) ? distance : 0; + this.decay = ( decay !== undefined ) ? decay : 1; // for physically correct lights, should be 2. + + this.shadow = new LightShadow( new PerspectiveCamera( 90, 1, 0.5, 500 ) ); + + } + + PointLight.prototype = Object.assign( Object.create( Light.prototype ), { + + constructor: PointLight, + + isPointLight: true, + + copy: function ( source ) { + + Light.prototype.copy.call( this, source ); + + this.distance = source.distance; + this.decay = source.decay; + + this.shadow = source.shadow.clone(); + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function DirectionalLightShadow( ) { + + LightShadow.call( this, new OrthographicCamera( - 5, 5, 5, - 5, 0.5, 500 ) ); + + } + + DirectionalLightShadow.prototype = Object.assign( Object.create( LightShadow.prototype ), { + + constructor: DirectionalLightShadow + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + */ + + function DirectionalLight( color, intensity ) { + + Light.call( this, color, intensity ); + + this.type = 'DirectionalLight'; + + this.position.copy( Object3D.DefaultUp ); + this.updateMatrix(); + + this.target = new Object3D(); + + this.shadow = new DirectionalLightShadow(); + + } + + DirectionalLight.prototype = Object.assign( Object.create( Light.prototype ), { + + constructor: DirectionalLight, + + isDirectionalLight: true, + + copy: function ( source ) { + + Light.prototype.copy.call( this, source ); + + this.target = source.target.clone(); + + this.shadow = source.shadow.clone(); + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function AmbientLight( color, intensity ) { + + Light.call( this, color, intensity ); + + this.type = 'AmbientLight'; + + this.castShadow = undefined; + + } + + AmbientLight.prototype = Object.assign( Object.create( Light.prototype ), { + + constructor: AmbientLight, + + isAmbientLight: true + + } ); + + /** + * @author abelnation / http://github.com/abelnation + */ + + function RectAreaLight( color, intensity, width, height ) { + + Light.call( this, color, intensity ); + + this.type = 'RectAreaLight'; + + this.width = ( width !== undefined ) ? width : 10; + this.height = ( height !== undefined ) ? height : 10; + + } + + RectAreaLight.prototype = Object.assign( Object.create( Light.prototype ), { + + constructor: RectAreaLight, + + isRectAreaLight: true, + + copy: function ( source ) { + + Light.prototype.copy.call( this, source ); + + this.width = source.width; + this.height = source.height; + + return this; + + }, + + toJSON: function ( meta ) { + + var data = Light.prototype.toJSON.call( this, meta ); + + data.object.width = this.width; + data.object.height = this.height; + + return data; + + } + + } ); + + /** + * + * A Track that interpolates Strings + * + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + */ + + function StringKeyframeTrack( name, times, values, interpolation ) { + + KeyframeTrack.call( this, name, times, values, interpolation ); + + } + + StringKeyframeTrack.prototype = Object.assign( Object.create( KeyframeTrack.prototype ), { + + constructor: StringKeyframeTrack, + + ValueTypeName: 'string', + ValueBufferType: Array, + + DefaultInterpolation: InterpolateDiscrete, + + InterpolantFactoryMethodLinear: undefined, + + InterpolantFactoryMethodSmooth: undefined + + } ); + + /** + * + * A Track of Boolean keyframe values. + * + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + */ + + function BooleanKeyframeTrack( name, times, values ) { + + KeyframeTrack.call( this, name, times, values ); + + } + + BooleanKeyframeTrack.prototype = Object.assign( Object.create( KeyframeTrack.prototype ), { + + constructor: BooleanKeyframeTrack, + + ValueTypeName: 'bool', + ValueBufferType: Array, + + DefaultInterpolation: InterpolateDiscrete, + + InterpolantFactoryMethodLinear: undefined, + InterpolantFactoryMethodSmooth: undefined + + // Note: Actually this track could have a optimized / compressed + // representation of a single value and a custom interpolant that + // computes "firstValue ^ isOdd( index )". + + } ); + + /** + * Abstract base class of interpolants over parametric samples. + * + * The parameter domain is one dimensional, typically the time or a path + * along a curve defined by the data. + * + * The sample values can have any dimensionality and derived classes may + * apply special interpretations to the data. + * + * This class provides the interval seek in a Template Method, deferring + * the actual interpolation to derived classes. + * + * Time complexity is O(1) for linear access crossing at most two points + * and O(log N) for random access, where N is the number of positions. + * + * References: + * + * http://www.oodesign.com/template-method-pattern.html + * + * @author tschw + */ + + function Interpolant( parameterPositions, sampleValues, sampleSize, resultBuffer ) { + + this.parameterPositions = parameterPositions; + this._cachedIndex = 0; + + this.resultBuffer = resultBuffer !== undefined ? + resultBuffer : new sampleValues.constructor( sampleSize ); + this.sampleValues = sampleValues; + this.valueSize = sampleSize; + + } + + Object.assign( Interpolant.prototype, { + + evaluate: function ( t ) { + + var pp = this.parameterPositions, + i1 = this._cachedIndex, + + t1 = pp[ i1 ], + t0 = pp[ i1 - 1 ]; + + validate_interval: { + + seek: { + + var right; + + linear_scan: { + + //- See http://jsperf.com/comparison-to-undefined/3 + //- slower code: + //- + //- if ( t >= t1 || t1 === undefined ) { + forward_scan: if ( ! ( t < t1 ) ) { + + for ( var giveUpAt = i1 + 2; ; ) { + + if ( t1 === undefined ) { + + if ( t < t0 ) break forward_scan; + + // after end + + i1 = pp.length; + this._cachedIndex = i1; + return this.afterEnd_( i1 - 1, t, t0 ); + + } + + if ( i1 === giveUpAt ) break; // this loop + + t0 = t1; + t1 = pp[ ++ i1 ]; + + if ( t < t1 ) { + + // we have arrived at the sought interval + break seek; + + } + + } + + // prepare binary search on the right side of the index + right = pp.length; + break linear_scan; + + } + + //- slower code: + //- if ( t < t0 || t0 === undefined ) { + if ( ! ( t >= t0 ) ) { + + // looping? + + var t1global = pp[ 1 ]; + + if ( t < t1global ) { + + i1 = 2; // + 1, using the scan for the details + t0 = t1global; + + } + + // linear reverse scan + + for ( var giveUpAt = i1 - 2; ; ) { + + if ( t0 === undefined ) { + + // before start + + this._cachedIndex = 0; + return this.beforeStart_( 0, t, t1 ); + + } + + if ( i1 === giveUpAt ) break; // this loop + + t1 = t0; + t0 = pp[ -- i1 - 1 ]; + + if ( t >= t0 ) { + + // we have arrived at the sought interval + break seek; + + } + + } + + // prepare binary search on the left side of the index + right = i1; + i1 = 0; + break linear_scan; + + } + + // the interval is valid + + break validate_interval; + + } // linear scan + + // binary search + + while ( i1 < right ) { + + var mid = ( i1 + right ) >>> 1; + + if ( t < pp[ mid ] ) { + + right = mid; + + } else { + + i1 = mid + 1; + + } + + } + + t1 = pp[ i1 ]; + t0 = pp[ i1 - 1 ]; + + // check boundary cases, again + + if ( t0 === undefined ) { + + this._cachedIndex = 0; + return this.beforeStart_( 0, t, t1 ); + + } + + if ( t1 === undefined ) { + + i1 = pp.length; + this._cachedIndex = i1; + return this.afterEnd_( i1 - 1, t0, t ); + + } + + } // seek + + this._cachedIndex = i1; + + this.intervalChanged_( i1, t0, t1 ); + + } // validate_interval + + return this.interpolate_( i1, t0, t, t1 ); + + }, + + settings: null, // optional, subclass-specific settings structure + // Note: The indirection allows central control of many interpolants. + + // --- Protected interface + + DefaultSettings_: {}, + + getSettings_: function () { + + return this.settings || this.DefaultSettings_; + + }, + + copySampleValue_: function ( index ) { + + // copies a sample value to the result buffer + + var result = this.resultBuffer, + values = this.sampleValues, + stride = this.valueSize, + offset = index * stride; + + for ( var i = 0; i !== stride; ++ i ) { + + result[ i ] = values[ offset + i ]; + + } + + return result; + + }, + + // Template methods for derived classes: + + interpolate_: function ( /* i1, t0, t, t1 */ ) { + + throw new Error( 'call to abstract method' ); + // implementations shall return this.resultBuffer + + }, + + intervalChanged_: function ( /* i1, t0, t1 */ ) { + + // empty + + } + + } ); + + //!\ DECLARE ALIAS AFTER assign prototype ! + Object.assign( Interpolant.prototype, { + + //( 0, t, t0 ), returns this.resultBuffer + beforeStart_: Interpolant.prototype.copySampleValue_, + + //( N-1, tN-1, t ), returns this.resultBuffer + afterEnd_: Interpolant.prototype.copySampleValue_, + + } ); + + /** + * Spherical linear unit quaternion interpolant. + * + * @author tschw + */ + + function QuaternionLinearInterpolant( parameterPositions, sampleValues, sampleSize, resultBuffer ) { + + Interpolant.call( this, parameterPositions, sampleValues, sampleSize, resultBuffer ); + + } + + QuaternionLinearInterpolant.prototype = Object.assign( Object.create( Interpolant.prototype ), { + + constructor: QuaternionLinearInterpolant, + + interpolate_: function ( i1, t0, t, t1 ) { + + var result = this.resultBuffer, + values = this.sampleValues, + stride = this.valueSize, + + offset = i1 * stride, + + alpha = ( t - t0 ) / ( t1 - t0 ); + + for ( var end = offset + stride; offset !== end; offset += 4 ) { + + Quaternion.slerpFlat( result, 0, values, offset - stride, values, offset, alpha ); + + } + + return result; + + } + + } ); + + /** + * + * A Track of quaternion keyframe values. + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + */ + + function QuaternionKeyframeTrack( name, times, values, interpolation ) { + + KeyframeTrack.call( this, name, times, values, interpolation ); + + } + + QuaternionKeyframeTrack.prototype = Object.assign( Object.create( KeyframeTrack.prototype ), { + + constructor: QuaternionKeyframeTrack, + + ValueTypeName: 'quaternion', + + // ValueBufferType is inherited + + DefaultInterpolation: InterpolateLinear, + + InterpolantFactoryMethodLinear: function ( result ) { + + return new QuaternionLinearInterpolant( this.times, this.values, this.getValueSize(), result ); + + }, + + InterpolantFactoryMethodSmooth: undefined // not yet implemented + + } ); + + /** + * + * A Track of keyframe values that represent color. + * + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + */ + + function ColorKeyframeTrack( name, times, values, interpolation ) { + + KeyframeTrack.call( this, name, times, values, interpolation ); + + } + + ColorKeyframeTrack.prototype = Object.assign( Object.create( KeyframeTrack.prototype ), { + + constructor: ColorKeyframeTrack, + + ValueTypeName: 'color' + + // ValueBufferType is inherited + + // DefaultInterpolation is inherited + + // Note: Very basic implementation and nothing special yet. + // However, this is the place for color space parameterization. + + } ); + + /** + * + * A Track of numeric keyframe values. + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + */ + + function NumberKeyframeTrack( name, times, values, interpolation ) { + + KeyframeTrack.call( this, name, times, values, interpolation ); + + } + + NumberKeyframeTrack.prototype = Object.assign( Object.create( KeyframeTrack.prototype ), { + + constructor: NumberKeyframeTrack, + + ValueTypeName: 'number' + + // ValueBufferType is inherited + + // DefaultInterpolation is inherited + + } ); + + /** + * Fast and simple cubic spline interpolant. + * + * It was derived from a Hermitian construction setting the first derivative + * at each sample position to the linear slope between neighboring positions + * over their parameter interval. + * + * @author tschw + */ + + function CubicInterpolant( parameterPositions, sampleValues, sampleSize, resultBuffer ) { + + Interpolant.call( this, parameterPositions, sampleValues, sampleSize, resultBuffer ); + + this._weightPrev = - 0; + this._offsetPrev = - 0; + this._weightNext = - 0; + this._offsetNext = - 0; + + } + + CubicInterpolant.prototype = Object.assign( Object.create( Interpolant.prototype ), { + + constructor: CubicInterpolant, + + DefaultSettings_: { + + endingStart: ZeroCurvatureEnding, + endingEnd: ZeroCurvatureEnding + + }, + + intervalChanged_: function ( i1, t0, t1 ) { + + var pp = this.parameterPositions, + iPrev = i1 - 2, + iNext = i1 + 1, + + tPrev = pp[ iPrev ], + tNext = pp[ iNext ]; + + if ( tPrev === undefined ) { + + switch ( this.getSettings_().endingStart ) { + + case ZeroSlopeEnding: + + // f'(t0) = 0 + iPrev = i1; + tPrev = 2 * t0 - t1; + + break; + + case WrapAroundEnding: + + // use the other end of the curve + iPrev = pp.length - 2; + tPrev = t0 + pp[ iPrev ] - pp[ iPrev + 1 ]; + + break; + + default: // ZeroCurvatureEnding + + // f''(t0) = 0 a.k.a. Natural Spline + iPrev = i1; + tPrev = t1; + + } + + } + + if ( tNext === undefined ) { + + switch ( this.getSettings_().endingEnd ) { + + case ZeroSlopeEnding: + + // f'(tN) = 0 + iNext = i1; + tNext = 2 * t1 - t0; + + break; + + case WrapAroundEnding: + + // use the other end of the curve + iNext = 1; + tNext = t1 + pp[ 1 ] - pp[ 0 ]; + + break; + + default: // ZeroCurvatureEnding + + // f''(tN) = 0, a.k.a. Natural Spline + iNext = i1 - 1; + tNext = t0; + + } + + } + + var halfDt = ( t1 - t0 ) * 0.5, + stride = this.valueSize; + + this._weightPrev = halfDt / ( t0 - tPrev ); + this._weightNext = halfDt / ( tNext - t1 ); + this._offsetPrev = iPrev * stride; + this._offsetNext = iNext * stride; + + }, + + interpolate_: function ( i1, t0, t, t1 ) { + + var result = this.resultBuffer, + values = this.sampleValues, + stride = this.valueSize, + + o1 = i1 * stride, o0 = o1 - stride, + oP = this._offsetPrev, oN = this._offsetNext, + wP = this._weightPrev, wN = this._weightNext, + + p = ( t - t0 ) / ( t1 - t0 ), + pp = p * p, + ppp = pp * p; + + // evaluate polynomials + + var sP = - wP * ppp + 2 * wP * pp - wP * p; + var s0 = ( 1 + wP ) * ppp + ( - 1.5 - 2 * wP ) * pp + ( - 0.5 + wP ) * p + 1; + var s1 = ( - 1 - wN ) * ppp + ( 1.5 + wN ) * pp + 0.5 * p; + var sN = wN * ppp - wN * pp; + + // combine data linearly + + for ( var i = 0; i !== stride; ++ i ) { + + result[ i ] = + sP * values[ oP + i ] + + s0 * values[ o0 + i ] + + s1 * values[ o1 + i ] + + sN * values[ oN + i ]; + + } + + return result; + + } + + } ); + + /** + * @author tschw + */ + + function LinearInterpolant( parameterPositions, sampleValues, sampleSize, resultBuffer ) { + + Interpolant.call( this, parameterPositions, sampleValues, sampleSize, resultBuffer ); + + } + + LinearInterpolant.prototype = Object.assign( Object.create( Interpolant.prototype ), { + + constructor: LinearInterpolant, + + interpolate_: function ( i1, t0, t, t1 ) { + + var result = this.resultBuffer, + values = this.sampleValues, + stride = this.valueSize, + + offset1 = i1 * stride, + offset0 = offset1 - stride, + + weight1 = ( t - t0 ) / ( t1 - t0 ), + weight0 = 1 - weight1; + + for ( var i = 0; i !== stride; ++ i ) { + + result[ i ] = + values[ offset0 + i ] * weight0 + + values[ offset1 + i ] * weight1; + + } + + return result; + + } + + } ); + + /** + * + * Interpolant that evaluates to the sample value at the position preceeding + * the parameter. + * + * @author tschw + */ + + function DiscreteInterpolant( parameterPositions, sampleValues, sampleSize, resultBuffer ) { + + Interpolant.call( this, parameterPositions, sampleValues, sampleSize, resultBuffer ); + + } + + DiscreteInterpolant.prototype = Object.assign( Object.create( Interpolant.prototype ), { + + constructor: DiscreteInterpolant, + + interpolate_: function ( i1 /*, t0, t, t1 */ ) { + + return this.copySampleValue_( i1 - 1 ); + + } + + } ); + + /** + * @author tschw + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + */ + + var AnimationUtils = { + + // same as Array.prototype.slice, but also works on typed arrays + arraySlice: function ( array, from, to ) { + + if ( AnimationUtils.isTypedArray( array ) ) { + + // in ios9 array.subarray(from, undefined) will return empty array + // but array.subarray(from) or array.subarray(from, len) is correct + return new array.constructor( array.subarray( from, to !== undefined ? to : array.length ) ); + + } + + return array.slice( from, to ); + + }, + + // converts an array to a specific type + convertArray: function ( array, type, forceClone ) { + + if ( ! array || // let 'undefined' and 'null' pass + ! forceClone && array.constructor === type ) return array; + + if ( typeof type.BYTES_PER_ELEMENT === 'number' ) { + + return new type( array ); // create typed array + + } + + return Array.prototype.slice.call( array ); // create Array + + }, + + isTypedArray: function ( object ) { + + return ArrayBuffer.isView( object ) && + ! ( object instanceof DataView ); + + }, + + // returns an array by which times and values can be sorted + getKeyframeOrder: function ( times ) { + + function compareTime( i, j ) { + + return times[ i ] - times[ j ]; + + } + + var n = times.length; + var result = new Array( n ); + for ( var i = 0; i !== n; ++ i ) result[ i ] = i; + + result.sort( compareTime ); + + return result; + + }, + + // uses the array previously returned by 'getKeyframeOrder' to sort data + sortedArray: function ( values, stride, order ) { + + var nValues = values.length; + var result = new values.constructor( nValues ); + + for ( var i = 0, dstOffset = 0; dstOffset !== nValues; ++ i ) { + + var srcOffset = order[ i ] * stride; + + for ( var j = 0; j !== stride; ++ j ) { + + result[ dstOffset ++ ] = values[ srcOffset + j ]; + + } + + } + + return result; + + }, + + // function for parsing AOS keyframe formats + flattenJSON: function ( jsonKeys, times, values, valuePropertyName ) { + + var i = 1, key = jsonKeys[ 0 ]; + + while ( key !== undefined && key[ valuePropertyName ] === undefined ) { + + key = jsonKeys[ i ++ ]; + + } + + if ( key === undefined ) return; // no data + + var value = key[ valuePropertyName ]; + if ( value === undefined ) return; // no data + + if ( Array.isArray( value ) ) { + + do { + + value = key[ valuePropertyName ]; + + if ( value !== undefined ) { + + times.push( key.time ); + values.push.apply( values, value ); // push all elements + + } + + key = jsonKeys[ i ++ ]; + + } while ( key !== undefined ); + + } else if ( value.toArray !== undefined ) { + + // ...assume THREE.Math-ish + + do { + + value = key[ valuePropertyName ]; + + if ( value !== undefined ) { + + times.push( key.time ); + value.toArray( values, values.length ); + + } + + key = jsonKeys[ i ++ ]; + + } while ( key !== undefined ); + + } else { + + // otherwise push as-is + + do { + + value = key[ valuePropertyName ]; + + if ( value !== undefined ) { + + times.push( key.time ); + values.push( value ); + + } + + key = jsonKeys[ i ++ ]; + + } while ( key !== undefined ); + + } + + } + + }; + + /** + * + * A timed sequence of keyframes for a specific property. + * + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + */ + + function KeyframeTrack( name, times, values, interpolation ) { + + if ( name === undefined ) throw new Error( 'THREE.KeyframeTrack: track name is undefined' ); + if ( times === undefined || times.length === 0 ) throw new Error( 'THREE.KeyframeTrack: no keyframes in track named ' + name ); + + this.name = name; + + this.times = AnimationUtils.convertArray( times, this.TimeBufferType ); + this.values = AnimationUtils.convertArray( values, this.ValueBufferType ); + + this.setInterpolation( interpolation || this.DefaultInterpolation ); + + this.validate(); + this.optimize(); + + } + + // Static methods: + + Object.assign( KeyframeTrack, { + + // Serialization (in static context, because of constructor invocation + // and automatic invocation of .toJSON): + + parse: function ( json ) { + + if ( json.type === undefined ) { + + throw new Error( 'THREE.KeyframeTrack: track type undefined, can not parse' ); + + } + + var trackType = KeyframeTrack._getTrackTypeForValueTypeName( json.type ); + + if ( json.times === undefined ) { + + var times = [], values = []; + + AnimationUtils.flattenJSON( json.keys, times, values, 'value' ); + + json.times = times; + json.values = values; + + } + + // derived classes can define a static parse method + if ( trackType.parse !== undefined ) { + + return trackType.parse( json ); + + } else { + + // by default, we assume a constructor compatible with the base + return new trackType( json.name, json.times, json.values, json.interpolation ); + + } + + }, + + toJSON: function ( track ) { + + var trackType = track.constructor; + + var json; + + // derived classes can define a static toJSON method + if ( trackType.toJSON !== undefined ) { + + json = trackType.toJSON( track ); + + } else { + + // by default, we assume the data can be serialized as-is + json = { + + 'name': track.name, + 'times': AnimationUtils.convertArray( track.times, Array ), + 'values': AnimationUtils.convertArray( track.values, Array ) + + }; + + var interpolation = track.getInterpolation(); + + if ( interpolation !== track.DefaultInterpolation ) { + + json.interpolation = interpolation; + + } + + } + + json.type = track.ValueTypeName; // mandatory + + return json; + + }, + + _getTrackTypeForValueTypeName: function ( typeName ) { + + switch ( typeName.toLowerCase() ) { + + case 'scalar': + case 'double': + case 'float': + case 'number': + case 'integer': + + return NumberKeyframeTrack; + + case 'vector': + case 'vector2': + case 'vector3': + case 'vector4': + + return VectorKeyframeTrack; + + case 'color': + + return ColorKeyframeTrack; + + case 'quaternion': + + return QuaternionKeyframeTrack; + + case 'bool': + case 'boolean': + + return BooleanKeyframeTrack; + + case 'string': + + return StringKeyframeTrack; + + } + + throw new Error( 'THREE.KeyframeTrack: Unsupported typeName: ' + typeName ); + + } + + } ); + + Object.assign( KeyframeTrack.prototype, { + + constructor: KeyframeTrack, + + TimeBufferType: Float32Array, + + ValueBufferType: Float32Array, + + DefaultInterpolation: InterpolateLinear, + + InterpolantFactoryMethodDiscrete: function ( result ) { + + return new DiscreteInterpolant( this.times, this.values, this.getValueSize(), result ); + + }, + + InterpolantFactoryMethodLinear: function ( result ) { + + return new LinearInterpolant( this.times, this.values, this.getValueSize(), result ); + + }, + + InterpolantFactoryMethodSmooth: function ( result ) { + + return new CubicInterpolant( this.times, this.values, this.getValueSize(), result ); + + }, + + setInterpolation: function ( interpolation ) { + + var factoryMethod; + + switch ( interpolation ) { + + case InterpolateDiscrete: + + factoryMethod = this.InterpolantFactoryMethodDiscrete; + + break; + + case InterpolateLinear: + + factoryMethod = this.InterpolantFactoryMethodLinear; + + break; + + case InterpolateSmooth: + + factoryMethod = this.InterpolantFactoryMethodSmooth; + + break; + + } + + if ( factoryMethod === undefined ) { + + var message = "unsupported interpolation for " + + this.ValueTypeName + " keyframe track named " + this.name; + + if ( this.createInterpolant === undefined ) { + + // fall back to default, unless the default itself is messed up + if ( interpolation !== this.DefaultInterpolation ) { + + this.setInterpolation( this.DefaultInterpolation ); + + } else { + + throw new Error( message ); // fatal, in this case + + } + + } + + console.warn( 'THREE.KeyframeTrack:', message ); + return; + + } + + this.createInterpolant = factoryMethod; + + }, + + getInterpolation: function () { + + switch ( this.createInterpolant ) { + + case this.InterpolantFactoryMethodDiscrete: + + return InterpolateDiscrete; + + case this.InterpolantFactoryMethodLinear: + + return InterpolateLinear; + + case this.InterpolantFactoryMethodSmooth: + + return InterpolateSmooth; + + } + + }, + + getValueSize: function () { + + return this.values.length / this.times.length; + + }, + + // move all keyframes either forwards or backwards in time + shift: function ( timeOffset ) { + + if ( timeOffset !== 0.0 ) { + + var times = this.times; + + for ( var i = 0, n = times.length; i !== n; ++ i ) { + + times[ i ] += timeOffset; + + } + + } + + return this; + + }, + + // scale all keyframe times by a factor (useful for frame <-> seconds conversions) + scale: function ( timeScale ) { + + if ( timeScale !== 1.0 ) { + + var times = this.times; + + for ( var i = 0, n = times.length; i !== n; ++ i ) { + + times[ i ] *= timeScale; + + } + + } + + return this; + + }, + + // removes keyframes before and after animation without changing any values within the range [startTime, endTime]. + // IMPORTANT: We do not shift around keys to the start of the track time, because for interpolated keys this will change their values + trim: function ( startTime, endTime ) { + + var times = this.times, + nKeys = times.length, + from = 0, + to = nKeys - 1; + + while ( from !== nKeys && times[ from ] < startTime ) { + + ++ from; + + } + + while ( to !== - 1 && times[ to ] > endTime ) { + + -- to; + + } + + ++ to; // inclusive -> exclusive bound + + if ( from !== 0 || to !== nKeys ) { + + // empty tracks are forbidden, so keep at least one keyframe + if ( from >= to ) to = Math.max( to, 1 ), from = to - 1; + + var stride = this.getValueSize(); + this.times = AnimationUtils.arraySlice( times, from, to ); + this.values = AnimationUtils.arraySlice( this.values, from * stride, to * stride ); + + } + + return this; + + }, + + // ensure we do not get a GarbageInGarbageOut situation, make sure tracks are at least minimally viable + validate: function () { + + var valid = true; + + var valueSize = this.getValueSize(); + if ( valueSize - Math.floor( valueSize ) !== 0 ) { + + console.error( 'THREE.KeyframeTrack: Invalid value size in track.', this ); + valid = false; + + } + + var times = this.times, + values = this.values, + + nKeys = times.length; + + if ( nKeys === 0 ) { + + console.error( 'THREE.KeyframeTrack: Track is empty.', this ); + valid = false; + + } + + var prevTime = null; + + for ( var i = 0; i !== nKeys; i ++ ) { + + var currTime = times[ i ]; + + if ( typeof currTime === 'number' && isNaN( currTime ) ) { + + console.error( 'THREE.KeyframeTrack: Time is not a valid number.', this, i, currTime ); + valid = false; + break; + + } + + if ( prevTime !== null && prevTime > currTime ) { + + console.error( 'THREE.KeyframeTrack: Out of order keys.', this, i, currTime, prevTime ); + valid = false; + break; + + } + + prevTime = currTime; + + } + + if ( values !== undefined ) { + + if ( AnimationUtils.isTypedArray( values ) ) { + + for ( var i = 0, n = values.length; i !== n; ++ i ) { + + var value = values[ i ]; + + if ( isNaN( value ) ) { + + console.error( 'THREE.KeyframeTrack: Value is not a valid number.', this, i, value ); + valid = false; + break; + + } + + } + + } + + } + + return valid; + + }, + + // removes equivalent sequential keys as common in morph target sequences + // (0,0,0,0,1,1,1,0,0,0,0,0,0,0) --> (0,0,1,1,0,0) + optimize: function () { + + var times = this.times, + values = this.values, + stride = this.getValueSize(), + + smoothInterpolation = this.getInterpolation() === InterpolateSmooth, + + writeIndex = 1, + lastIndex = times.length - 1; + + for ( var i = 1; i < lastIndex; ++ i ) { + + var keep = false; + + var time = times[ i ]; + var timeNext = times[ i + 1 ]; + + // remove adjacent keyframes scheduled at the same time + + if ( time !== timeNext && ( i !== 1 || time !== time[ 0 ] ) ) { + + if ( ! smoothInterpolation ) { + + // remove unnecessary keyframes same as their neighbors + + var offset = i * stride, + offsetP = offset - stride, + offsetN = offset + stride; + + for ( var j = 0; j !== stride; ++ j ) { + + var value = values[ offset + j ]; + + if ( value !== values[ offsetP + j ] || + value !== values[ offsetN + j ] ) { + + keep = true; + break; + + } + + } + + } else { + + keep = true; + + } + + } + + // in-place compaction + + if ( keep ) { + + if ( i !== writeIndex ) { + + times[ writeIndex ] = times[ i ]; + + var readOffset = i * stride, + writeOffset = writeIndex * stride; + + for ( var j = 0; j !== stride; ++ j ) { + + values[ writeOffset + j ] = values[ readOffset + j ]; + + } + + } + + ++ writeIndex; + + } + + } + + // flush last keyframe (compaction looks ahead) + + if ( lastIndex > 0 ) { + + times[ writeIndex ] = times[ lastIndex ]; + + for ( var readOffset = lastIndex * stride, writeOffset = writeIndex * stride, j = 0; j !== stride; ++ j ) { + + values[ writeOffset + j ] = values[ readOffset + j ]; + + } + + ++ writeIndex; + + } + + if ( writeIndex !== times.length ) { + + this.times = AnimationUtils.arraySlice( times, 0, writeIndex ); + this.values = AnimationUtils.arraySlice( values, 0, writeIndex * stride ); + + } + + return this; + + } + + } ); + + /** + * + * A Track of vectored keyframe values. + * + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + */ + + function VectorKeyframeTrack( name, times, values, interpolation ) { + + KeyframeTrack.call( this, name, times, values, interpolation ); + + } + + VectorKeyframeTrack.prototype = Object.assign( Object.create( KeyframeTrack.prototype ), { + + constructor: VectorKeyframeTrack, + + ValueTypeName: 'vector' + + // ValueBufferType is inherited + + // DefaultInterpolation is inherited + + } ); + + /** + * + * Reusable set of Tracks that represent an animation. + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + */ + + function AnimationClip( name, duration, tracks ) { + + this.name = name; + this.tracks = tracks; + this.duration = ( duration !== undefined ) ? duration : - 1; + + this.uuid = _Math.generateUUID(); + + // this means it should figure out its duration by scanning the tracks + if ( this.duration < 0 ) { + + this.resetDuration(); + + } + + this.optimize(); + + } + + Object.assign( AnimationClip, { + + parse: function ( json ) { + + var tracks = [], + jsonTracks = json.tracks, + frameTime = 1.0 / ( json.fps || 1.0 ); + + for ( var i = 0, n = jsonTracks.length; i !== n; ++ i ) { + + tracks.push( KeyframeTrack.parse( jsonTracks[ i ] ).scale( frameTime ) ); + + } + + return new AnimationClip( json.name, json.duration, tracks ); + + }, + + toJSON: function ( clip ) { + + var tracks = [], + clipTracks = clip.tracks; + + var json = { + + 'name': clip.name, + 'duration': clip.duration, + 'tracks': tracks, + 'uuid': clip.uuid + + }; + + for ( var i = 0, n = clipTracks.length; i !== n; ++ i ) { + + tracks.push( KeyframeTrack.toJSON( clipTracks[ i ] ) ); + + } + + return json; + + }, + + CreateFromMorphTargetSequence: function ( name, morphTargetSequence, fps, noLoop ) { + + var numMorphTargets = morphTargetSequence.length; + var tracks = []; + + for ( var i = 0; i < numMorphTargets; i ++ ) { + + var times = []; + var values = []; + + times.push( + ( i + numMorphTargets - 1 ) % numMorphTargets, + i, + ( i + 1 ) % numMorphTargets ); + + values.push( 0, 1, 0 ); + + var order = AnimationUtils.getKeyframeOrder( times ); + times = AnimationUtils.sortedArray( times, 1, order ); + values = AnimationUtils.sortedArray( values, 1, order ); + + // if there is a key at the first frame, duplicate it as the + // last frame as well for perfect loop. + if ( ! noLoop && times[ 0 ] === 0 ) { + + times.push( numMorphTargets ); + values.push( values[ 0 ] ); + + } + + tracks.push( + new NumberKeyframeTrack( + '.morphTargetInfluences[' + morphTargetSequence[ i ].name + ']', + times, values + ).scale( 1.0 / fps ) ); + + } + + return new AnimationClip( name, - 1, tracks ); + + }, + + findByName: function ( objectOrClipArray, name ) { + + var clipArray = objectOrClipArray; + + if ( ! Array.isArray( objectOrClipArray ) ) { + + var o = objectOrClipArray; + clipArray = o.geometry && o.geometry.animations || o.animations; + + } + + for ( var i = 0; i < clipArray.length; i ++ ) { + + if ( clipArray[ i ].name === name ) { + + return clipArray[ i ]; + + } + + } + + return null; + + }, + + CreateClipsFromMorphTargetSequences: function ( morphTargets, fps, noLoop ) { + + var animationToMorphTargets = {}; + + // tested with https://regex101.com/ on trick sequences + // such flamingo_flyA_003, flamingo_run1_003, crdeath0059 + var pattern = /^([\w-]*?)([\d]+)$/; + + // sort morph target names into animation groups based + // patterns like Walk_001, Walk_002, Run_001, Run_002 + for ( var i = 0, il = morphTargets.length; i < il; i ++ ) { + + var morphTarget = morphTargets[ i ]; + var parts = morphTarget.name.match( pattern ); + + if ( parts && parts.length > 1 ) { + + var name = parts[ 1 ]; + + var animationMorphTargets = animationToMorphTargets[ name ]; + if ( ! animationMorphTargets ) { + + animationToMorphTargets[ name ] = animationMorphTargets = []; + + } + + animationMorphTargets.push( morphTarget ); + + } + + } + + var clips = []; + + for ( var name in animationToMorphTargets ) { + + clips.push( AnimationClip.CreateFromMorphTargetSequence( name, animationToMorphTargets[ name ], fps, noLoop ) ); + + } + + return clips; + + }, + + // parse the animation.hierarchy format + parseAnimation: function ( animation, bones ) { + + if ( ! animation ) { + + console.error( 'THREE.AnimationClip: No animation in JSONLoader data.' ); + return null; + + } + + var addNonemptyTrack = function ( trackType, trackName, animationKeys, propertyName, destTracks ) { + + // only return track if there are actually keys. + if ( animationKeys.length !== 0 ) { + + var times = []; + var values = []; + + AnimationUtils.flattenJSON( animationKeys, times, values, propertyName ); + + // empty keys are filtered out, so check again + if ( times.length !== 0 ) { + + destTracks.push( new trackType( trackName, times, values ) ); + + } + + } + + }; + + var tracks = []; + + var clipName = animation.name || 'default'; + // automatic length determination in AnimationClip. + var duration = animation.length || - 1; + var fps = animation.fps || 30; + + var hierarchyTracks = animation.hierarchy || []; + + for ( var h = 0; h < hierarchyTracks.length; h ++ ) { + + var animationKeys = hierarchyTracks[ h ].keys; + + // skip empty tracks + if ( ! animationKeys || animationKeys.length === 0 ) continue; + + // process morph targets + if ( animationKeys[ 0 ].morphTargets ) { + + // figure out all morph targets used in this track + var morphTargetNames = {}; + + for ( var k = 0; k < animationKeys.length; k ++ ) { + + if ( animationKeys[ k ].morphTargets ) { + + for ( var m = 0; m < animationKeys[ k ].morphTargets.length; m ++ ) { + + morphTargetNames[ animationKeys[ k ].morphTargets[ m ] ] = - 1; + + } + + } + + } + + // create a track for each morph target with all zero + // morphTargetInfluences except for the keys in which + // the morphTarget is named. + for ( var morphTargetName in morphTargetNames ) { + + var times = []; + var values = []; + + for ( var m = 0; m !== animationKeys[ k ].morphTargets.length; ++ m ) { + + var animationKey = animationKeys[ k ]; + + times.push( animationKey.time ); + values.push( ( animationKey.morphTarget === morphTargetName ) ? 1 : 0 ); + + } + + tracks.push( new NumberKeyframeTrack( '.morphTargetInfluence[' + morphTargetName + ']', times, values ) ); + + } + + duration = morphTargetNames.length * ( fps || 1.0 ); + + } else { + + // ...assume skeletal animation + + var boneName = '.bones[' + bones[ h ].name + ']'; + + addNonemptyTrack( + VectorKeyframeTrack, boneName + '.position', + animationKeys, 'pos', tracks ); + + addNonemptyTrack( + QuaternionKeyframeTrack, boneName + '.quaternion', + animationKeys, 'rot', tracks ); + + addNonemptyTrack( + VectorKeyframeTrack, boneName + '.scale', + animationKeys, 'scl', tracks ); + + } + + } + + if ( tracks.length === 0 ) { + + return null; + + } + + var clip = new AnimationClip( clipName, duration, tracks ); + + return clip; + + } + + } ); + + Object.assign( AnimationClip.prototype, { + + resetDuration: function () { + + var tracks = this.tracks, duration = 0; + + for ( var i = 0, n = tracks.length; i !== n; ++ i ) { + + var track = this.tracks[ i ]; + + duration = Math.max( duration, track.times[ track.times.length - 1 ] ); + + } + + this.duration = duration; + + }, + + trim: function () { + + for ( var i = 0; i < this.tracks.length; i ++ ) { + + this.tracks[ i ].trim( 0, this.duration ); + + } + + return this; + + }, + + optimize: function () { + + for ( var i = 0; i < this.tracks.length; i ++ ) { + + this.tracks[ i ].optimize(); + + } + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function MaterialLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + this.textures = {}; + + } + + Object.assign( MaterialLoader.prototype, { + + load: function ( url, onLoad, onProgress, onError ) { + + var scope = this; + + var loader = new FileLoader( scope.manager ); + loader.load( url, function ( text ) { + + onLoad( scope.parse( JSON.parse( text ) ) ); + + }, onProgress, onError ); + + }, + + setTextures: function ( value ) { + + this.textures = value; + + }, + + parse: function ( json ) { + + var textures = this.textures; + + function getTexture( name ) { + + if ( textures[ name ] === undefined ) { + + console.warn( 'THREE.MaterialLoader: Undefined texture', name ); + + } + + return textures[ name ]; + + } + + var material = new Materials[ json.type ](); + + if ( json.uuid !== undefined ) material.uuid = json.uuid; + if ( json.name !== undefined ) material.name = json.name; + if ( json.color !== undefined ) material.color.setHex( json.color ); + if ( json.roughness !== undefined ) material.roughness = json.roughness; + if ( json.metalness !== undefined ) material.metalness = json.metalness; + if ( json.emissive !== undefined ) material.emissive.setHex( json.emissive ); + if ( json.specular !== undefined ) material.specular.setHex( json.specular ); + if ( json.shininess !== undefined ) material.shininess = json.shininess; + if ( json.clearCoat !== undefined ) material.clearCoat = json.clearCoat; + if ( json.clearCoatRoughness !== undefined ) material.clearCoatRoughness = json.clearCoatRoughness; + if ( json.uniforms !== undefined ) material.uniforms = json.uniforms; + if ( json.vertexShader !== undefined ) material.vertexShader = json.vertexShader; + if ( json.fragmentShader !== undefined ) material.fragmentShader = json.fragmentShader; + if ( json.vertexColors !== undefined ) material.vertexColors = json.vertexColors; + if ( json.fog !== undefined ) material.fog = json.fog; + if ( json.flatShading !== undefined ) material.flatShading = json.flatShading; + if ( json.blending !== undefined ) material.blending = json.blending; + if ( json.side !== undefined ) material.side = json.side; + if ( json.opacity !== undefined ) material.opacity = json.opacity; + if ( json.transparent !== undefined ) material.transparent = json.transparent; + if ( json.alphaTest !== undefined ) material.alphaTest = json.alphaTest; + if ( json.depthTest !== undefined ) material.depthTest = json.depthTest; + if ( json.depthWrite !== undefined ) material.depthWrite = json.depthWrite; + if ( json.colorWrite !== undefined ) material.colorWrite = json.colorWrite; + if ( json.wireframe !== undefined ) material.wireframe = json.wireframe; + if ( json.wireframeLinewidth !== undefined ) material.wireframeLinewidth = json.wireframeLinewidth; + if ( json.wireframeLinecap !== undefined ) material.wireframeLinecap = json.wireframeLinecap; + if ( json.wireframeLinejoin !== undefined ) material.wireframeLinejoin = json.wireframeLinejoin; + + if ( json.rotation !== undefined ) material.rotation = json.rotation; + + if ( json.linewidth !== 1 ) material.linewidth = json.linewidth; + if ( json.dashSize !== undefined ) material.dashSize = json.dashSize; + if ( json.gapSize !== undefined ) material.gapSize = json.gapSize; + if ( json.scale !== undefined ) material.scale = json.scale; + + if ( json.polygonOffset !== undefined ) material.polygonOffset = json.polygonOffset; + if ( json.polygonOffsetFactor !== undefined ) material.polygonOffsetFactor = json.polygonOffsetFactor; + if ( json.polygonOffsetUnits !== undefined ) material.polygonOffsetUnits = json.polygonOffsetUnits; + + if ( json.skinning !== undefined ) material.skinning = json.skinning; + if ( json.morphTargets !== undefined ) material.morphTargets = json.morphTargets; + if ( json.dithering !== undefined ) material.dithering = json.dithering; + + if ( json.visible !== undefined ) material.visible = json.visible; + if ( json.userData !== undefined ) material.userData = json.userData; + + // Deprecated + + if ( json.shading !== undefined ) material.flatShading = json.shading === 1; // THREE.FlatShading + + // for PointsMaterial + + if ( json.size !== undefined ) material.size = json.size; + if ( json.sizeAttenuation !== undefined ) material.sizeAttenuation = json.sizeAttenuation; + + // maps + + if ( json.map !== undefined ) material.map = getTexture( json.map ); + + if ( json.alphaMap !== undefined ) { + + material.alphaMap = getTexture( json.alphaMap ); + material.transparent = true; + + } + + if ( json.bumpMap !== undefined ) material.bumpMap = getTexture( json.bumpMap ); + if ( json.bumpScale !== undefined ) material.bumpScale = json.bumpScale; + + if ( json.normalMap !== undefined ) material.normalMap = getTexture( json.normalMap ); + if ( json.normalScale !== undefined ) { + + var normalScale = json.normalScale; + + if ( Array.isArray( normalScale ) === false ) { + + // Blender exporter used to export a scalar. See #7459 + + normalScale = [ normalScale, normalScale ]; + + } + + material.normalScale = new Vector2().fromArray( normalScale ); + + } + + if ( json.displacementMap !== undefined ) material.displacementMap = getTexture( json.displacementMap ); + if ( json.displacementScale !== undefined ) material.displacementScale = json.displacementScale; + if ( json.displacementBias !== undefined ) material.displacementBias = json.displacementBias; + + if ( json.roughnessMap !== undefined ) material.roughnessMap = getTexture( json.roughnessMap ); + if ( json.metalnessMap !== undefined ) material.metalnessMap = getTexture( json.metalnessMap ); + + if ( json.emissiveMap !== undefined ) material.emissiveMap = getTexture( json.emissiveMap ); + if ( json.emissiveIntensity !== undefined ) material.emissiveIntensity = json.emissiveIntensity; + + if ( json.specularMap !== undefined ) material.specularMap = getTexture( json.specularMap ); + + if ( json.envMap !== undefined ) material.envMap = getTexture( json.envMap ); + + if ( json.reflectivity !== undefined ) material.reflectivity = json.reflectivity; + + if ( json.lightMap !== undefined ) material.lightMap = getTexture( json.lightMap ); + if ( json.lightMapIntensity !== undefined ) material.lightMapIntensity = json.lightMapIntensity; + + if ( json.aoMap !== undefined ) material.aoMap = getTexture( json.aoMap ); + if ( json.aoMapIntensity !== undefined ) material.aoMapIntensity = json.aoMapIntensity; + + if ( json.gradientMap !== undefined ) material.gradientMap = getTexture( json.gradientMap ); + + return material; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function BufferGeometryLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + + } + + Object.assign( BufferGeometryLoader.prototype, { + + load: function ( url, onLoad, onProgress, onError ) { + + var scope = this; + + var loader = new FileLoader( scope.manager ); + loader.load( url, function ( text ) { + + onLoad( scope.parse( JSON.parse( text ) ) ); + + }, onProgress, onError ); + + }, + + parse: function ( json ) { + + var geometry = new BufferGeometry(); + + var index = json.data.index; + + if ( index !== undefined ) { + + var typedArray = new TYPED_ARRAYS[ index.type ]( index.array ); + geometry.setIndex( new BufferAttribute( typedArray, 1 ) ); + + } + + var attributes = json.data.attributes; + + for ( var key in attributes ) { + + var attribute = attributes[ key ]; + var typedArray = new TYPED_ARRAYS[ attribute.type ]( attribute.array ); + + geometry.addAttribute( key, new BufferAttribute( typedArray, attribute.itemSize, attribute.normalized ) ); + + } + + var groups = json.data.groups || json.data.drawcalls || json.data.offsets; + + if ( groups !== undefined ) { + + for ( var i = 0, n = groups.length; i !== n; ++ i ) { + + var group = groups[ i ]; + + geometry.addGroup( group.start, group.count, group.materialIndex ); + + } + + } + + var boundingSphere = json.data.boundingSphere; + + if ( boundingSphere !== undefined ) { + + var center = new Vector3(); + + if ( boundingSphere.center !== undefined ) { + + center.fromArray( boundingSphere.center ); + + } + + geometry.boundingSphere = new Sphere( center, boundingSphere.radius ); + + } + + return geometry; + + } + + } ); + + var TYPED_ARRAYS = { + Int8Array: Int8Array, + Uint8Array: Uint8Array, + // Workaround for IE11 pre KB2929437. See #11440 + Uint8ClampedArray: typeof Uint8ClampedArray !== 'undefined' ? Uint8ClampedArray : Uint8Array, + Int16Array: Int16Array, + Uint16Array: Uint16Array, + Int32Array: Int32Array, + Uint32Array: Uint32Array, + Float32Array: Float32Array, + Float64Array: Float64Array + }; + + /** + * @author alteredq / http://alteredqualia.com/ + */ + + function Loader() {} + + Loader.Handlers = { + + handlers: [], + + add: function ( regex, loader ) { + + this.handlers.push( regex, loader ); + + }, + + get: function ( file ) { + + var handlers = this.handlers; + + for ( var i = 0, l = handlers.length; i < l; i += 2 ) { + + var regex = handlers[ i ]; + var loader = handlers[ i + 1 ]; + + if ( regex.test( file ) ) { + + return loader; + + } + + } + + return null; + + } + + }; + + Object.assign( Loader.prototype, { + + crossOrigin: undefined, + + onLoadStart: function () {}, + + onLoadProgress: function () {}, + + onLoadComplete: function () {}, + + initMaterials: function ( materials, texturePath, crossOrigin ) { + + var array = []; + + for ( var i = 0; i < materials.length; ++ i ) { + + array[ i ] = this.createMaterial( materials[ i ], texturePath, crossOrigin ); + + } + + return array; + + }, + + createMaterial: ( function () { + + var BlendingMode = { + NoBlending: NoBlending, + NormalBlending: NormalBlending, + AdditiveBlending: AdditiveBlending, + SubtractiveBlending: SubtractiveBlending, + MultiplyBlending: MultiplyBlending, + CustomBlending: CustomBlending + }; + + var color = new Color(); + var textureLoader = new TextureLoader(); + var materialLoader = new MaterialLoader(); + + return function createMaterial( m, texturePath, crossOrigin ) { + + // convert from old material format + + var textures = {}; + + function loadTexture( path, repeat, offset, wrap, anisotropy ) { + + var fullPath = texturePath + path; + var loader = Loader.Handlers.get( fullPath ); + + var texture; + + if ( loader !== null ) { + + texture = loader.load( fullPath ); + + } else { + + textureLoader.setCrossOrigin( crossOrigin ); + texture = textureLoader.load( fullPath ); + + } + + if ( repeat !== undefined ) { + + texture.repeat.fromArray( repeat ); + + if ( repeat[ 0 ] !== 1 ) texture.wrapS = RepeatWrapping; + if ( repeat[ 1 ] !== 1 ) texture.wrapT = RepeatWrapping; + + } + + if ( offset !== undefined ) { + + texture.offset.fromArray( offset ); + + } + + if ( wrap !== undefined ) { + + if ( wrap[ 0 ] === 'repeat' ) texture.wrapS = RepeatWrapping; + if ( wrap[ 0 ] === 'mirror' ) texture.wrapS = MirroredRepeatWrapping; + + if ( wrap[ 1 ] === 'repeat' ) texture.wrapT = RepeatWrapping; + if ( wrap[ 1 ] === 'mirror' ) texture.wrapT = MirroredRepeatWrapping; + + } + + if ( anisotropy !== undefined ) { + + texture.anisotropy = anisotropy; + + } + + var uuid = _Math.generateUUID(); + + textures[ uuid ] = texture; + + return uuid; + + } + + // + + var json = { + uuid: _Math.generateUUID(), + type: 'MeshLambertMaterial' + }; + + for ( var name in m ) { + + var value = m[ name ]; + + switch ( name ) { + + case 'DbgColor': + case 'DbgIndex': + case 'opticalDensity': + case 'illumination': + break; + case 'DbgName': + json.name = value; + break; + case 'blending': + json.blending = BlendingMode[ value ]; + break; + case 'colorAmbient': + case 'mapAmbient': + console.warn( 'THREE.Loader.createMaterial:', name, 'is no longer supported.' ); + break; + case 'colorDiffuse': + json.color = color.fromArray( value ).getHex(); + break; + case 'colorSpecular': + json.specular = color.fromArray( value ).getHex(); + break; + case 'colorEmissive': + json.emissive = color.fromArray( value ).getHex(); + break; + case 'specularCoef': + json.shininess = value; + break; + case 'shading': + if ( value.toLowerCase() === 'basic' ) json.type = 'MeshBasicMaterial'; + if ( value.toLowerCase() === 'phong' ) json.type = 'MeshPhongMaterial'; + if ( value.toLowerCase() === 'standard' ) json.type = 'MeshStandardMaterial'; + break; + case 'mapDiffuse': + json.map = loadTexture( value, m.mapDiffuseRepeat, m.mapDiffuseOffset, m.mapDiffuseWrap, m.mapDiffuseAnisotropy ); + break; + case 'mapDiffuseRepeat': + case 'mapDiffuseOffset': + case 'mapDiffuseWrap': + case 'mapDiffuseAnisotropy': + break; + case 'mapEmissive': + json.emissiveMap = loadTexture( value, m.mapEmissiveRepeat, m.mapEmissiveOffset, m.mapEmissiveWrap, m.mapEmissiveAnisotropy ); + break; + case 'mapEmissiveRepeat': + case 'mapEmissiveOffset': + case 'mapEmissiveWrap': + case 'mapEmissiveAnisotropy': + break; + case 'mapLight': + json.lightMap = loadTexture( value, m.mapLightRepeat, m.mapLightOffset, m.mapLightWrap, m.mapLightAnisotropy ); + break; + case 'mapLightRepeat': + case 'mapLightOffset': + case 'mapLightWrap': + case 'mapLightAnisotropy': + break; + case 'mapAO': + json.aoMap = loadTexture( value, m.mapAORepeat, m.mapAOOffset, m.mapAOWrap, m.mapAOAnisotropy ); + break; + case 'mapAORepeat': + case 'mapAOOffset': + case 'mapAOWrap': + case 'mapAOAnisotropy': + break; + case 'mapBump': + json.bumpMap = loadTexture( value, m.mapBumpRepeat, m.mapBumpOffset, m.mapBumpWrap, m.mapBumpAnisotropy ); + break; + case 'mapBumpScale': + json.bumpScale = value; + break; + case 'mapBumpRepeat': + case 'mapBumpOffset': + case 'mapBumpWrap': + case 'mapBumpAnisotropy': + break; + case 'mapNormal': + json.normalMap = loadTexture( value, m.mapNormalRepeat, m.mapNormalOffset, m.mapNormalWrap, m.mapNormalAnisotropy ); + break; + case 'mapNormalFactor': + json.normalScale = value; + break; + case 'mapNormalRepeat': + case 'mapNormalOffset': + case 'mapNormalWrap': + case 'mapNormalAnisotropy': + break; + case 'mapSpecular': + json.specularMap = loadTexture( value, m.mapSpecularRepeat, m.mapSpecularOffset, m.mapSpecularWrap, m.mapSpecularAnisotropy ); + break; + case 'mapSpecularRepeat': + case 'mapSpecularOffset': + case 'mapSpecularWrap': + case 'mapSpecularAnisotropy': + break; + case 'mapMetalness': + json.metalnessMap = loadTexture( value, m.mapMetalnessRepeat, m.mapMetalnessOffset, m.mapMetalnessWrap, m.mapMetalnessAnisotropy ); + break; + case 'mapMetalnessRepeat': + case 'mapMetalnessOffset': + case 'mapMetalnessWrap': + case 'mapMetalnessAnisotropy': + break; + case 'mapRoughness': + json.roughnessMap = loadTexture( value, m.mapRoughnessRepeat, m.mapRoughnessOffset, m.mapRoughnessWrap, m.mapRoughnessAnisotropy ); + break; + case 'mapRoughnessRepeat': + case 'mapRoughnessOffset': + case 'mapRoughnessWrap': + case 'mapRoughnessAnisotropy': + break; + case 'mapAlpha': + json.alphaMap = loadTexture( value, m.mapAlphaRepeat, m.mapAlphaOffset, m.mapAlphaWrap, m.mapAlphaAnisotropy ); + break; + case 'mapAlphaRepeat': + case 'mapAlphaOffset': + case 'mapAlphaWrap': + case 'mapAlphaAnisotropy': + break; + case 'flipSided': + json.side = BackSide; + break; + case 'doubleSided': + json.side = DoubleSide; + break; + case 'transparency': + console.warn( 'THREE.Loader.createMaterial: transparency has been renamed to opacity' ); + json.opacity = value; + break; + case 'depthTest': + case 'depthWrite': + case 'colorWrite': + case 'opacity': + case 'reflectivity': + case 'transparent': + case 'visible': + case 'wireframe': + json[ name ] = value; + break; + case 'vertexColors': + if ( value === true ) json.vertexColors = VertexColors; + if ( value === 'face' ) json.vertexColors = FaceColors; + break; + default: + console.error( 'THREE.Loader.createMaterial: Unsupported', name, value ); + break; + + } + + } + + if ( json.type === 'MeshBasicMaterial' ) delete json.emissive; + if ( json.type !== 'MeshPhongMaterial' ) delete json.specular; + + if ( json.opacity < 1 ) json.transparent = true; + + materialLoader.setTextures( textures ); + + return materialLoader.parse( json ); + + }; + + } )() + + } ); + + /** + * @author Don McCurdy / https://www.donmccurdy.com + */ + + var LoaderUtils = { + + decodeText: function ( array ) { + + if ( typeof TextDecoder !== 'undefined' ) { + + return new TextDecoder().decode( array ); + + } + + // Avoid the String.fromCharCode.apply(null, array) shortcut, which + // throws a "maximum call stack size exceeded" error for large arrays. + + var s = ''; + + for ( var i = 0, il = array.length; i < il; i ++ ) { + + // Implicitly assumes little-endian. + s += String.fromCharCode( array[ i ] ); + + } + + // Merges multi-byte utf-8 characters. + return decodeURIComponent( escape( s ) ); + + }, + + extractUrlBase: function ( url ) { + + var index = url.lastIndexOf( '/' ); + + if ( index === - 1 ) return './'; + + return url.substr( 0, index + 1 ); + + } + + }; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author alteredq / http://alteredqualia.com/ + */ + + function JSONLoader( manager ) { + + if ( typeof manager === 'boolean' ) { + + console.warn( 'THREE.JSONLoader: showStatus parameter has been removed from constructor.' ); + manager = undefined; + + } + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + + this.withCredentials = false; + + } + + Object.assign( JSONLoader.prototype, { + + load: function ( url, onLoad, onProgress, onError ) { + + var scope = this; + + var texturePath = this.texturePath && ( typeof this.texturePath === 'string' ) ? this.texturePath : LoaderUtils.extractUrlBase( url ); + + var loader = new FileLoader( this.manager ); + loader.setWithCredentials( this.withCredentials ); + loader.load( url, function ( text ) { + + var json = JSON.parse( text ); + var metadata = json.metadata; + + if ( metadata !== undefined ) { + + var type = metadata.type; + + if ( type !== undefined ) { + + if ( type.toLowerCase() === 'object' ) { + + console.error( 'THREE.JSONLoader: ' + url + ' should be loaded with THREE.ObjectLoader instead.' ); + return; + + } + + } + + } + + var object = scope.parse( json, texturePath ); + onLoad( object.geometry, object.materials ); + + }, onProgress, onError ); + + }, + + setTexturePath: function ( value ) { + + this.texturePath = value; + + }, + + parse: ( function () { + + function parseModel( json, geometry ) { + + function isBitSet( value, position ) { + + return value & ( 1 << position ); + + } + + var i, j, fi, + + offset, zLength, + + colorIndex, normalIndex, uvIndex, materialIndex, + + type, + isQuad, + hasMaterial, + hasFaceVertexUv, + hasFaceNormal, hasFaceVertexNormal, + hasFaceColor, hasFaceVertexColor, + + vertex, face, faceA, faceB, hex, normal, + + uvLayer, uv, u, v, + + faces = json.faces, + vertices = json.vertices, + normals = json.normals, + colors = json.colors, + + scale = json.scale, + + nUvLayers = 0; + + + if ( json.uvs !== undefined ) { + + // disregard empty arrays + + for ( i = 0; i < json.uvs.length; i ++ ) { + + if ( json.uvs[ i ].length ) nUvLayers ++; + + } + + for ( i = 0; i < nUvLayers; i ++ ) { + + geometry.faceVertexUvs[ i ] = []; + + } + + } + + offset = 0; + zLength = vertices.length; + + while ( offset < zLength ) { + + vertex = new Vector3(); + + vertex.x = vertices[ offset ++ ] * scale; + vertex.y = vertices[ offset ++ ] * scale; + vertex.z = vertices[ offset ++ ] * scale; + + geometry.vertices.push( vertex ); + + } + + offset = 0; + zLength = faces.length; + + while ( offset < zLength ) { + + type = faces[ offset ++ ]; + + isQuad = isBitSet( type, 0 ); + hasMaterial = isBitSet( type, 1 ); + hasFaceVertexUv = isBitSet( type, 3 ); + hasFaceNormal = isBitSet( type, 4 ); + hasFaceVertexNormal = isBitSet( type, 5 ); + hasFaceColor = isBitSet( type, 6 ); + hasFaceVertexColor = isBitSet( type, 7 ); + + // console.log("type", type, "bits", isQuad, hasMaterial, hasFaceVertexUv, hasFaceNormal, hasFaceVertexNormal, hasFaceColor, hasFaceVertexColor); + + if ( isQuad ) { + + faceA = new Face3(); + faceA.a = faces[ offset ]; + faceA.b = faces[ offset + 1 ]; + faceA.c = faces[ offset + 3 ]; + + faceB = new Face3(); + faceB.a = faces[ offset + 1 ]; + faceB.b = faces[ offset + 2 ]; + faceB.c = faces[ offset + 3 ]; + + offset += 4; + + if ( hasMaterial ) { + + materialIndex = faces[ offset ++ ]; + faceA.materialIndex = materialIndex; + faceB.materialIndex = materialIndex; + + } + + // to get face <=> uv index correspondence + + fi = geometry.faces.length; + + if ( hasFaceVertexUv ) { + + for ( i = 0; i < nUvLayers; i ++ ) { + + uvLayer = json.uvs[ i ]; + + geometry.faceVertexUvs[ i ][ fi ] = []; + geometry.faceVertexUvs[ i ][ fi + 1 ] = []; + + for ( j = 0; j < 4; j ++ ) { + + uvIndex = faces[ offset ++ ]; + + u = uvLayer[ uvIndex * 2 ]; + v = uvLayer[ uvIndex * 2 + 1 ]; + + uv = new Vector2( u, v ); + + if ( j !== 2 ) geometry.faceVertexUvs[ i ][ fi ].push( uv ); + if ( j !== 0 ) geometry.faceVertexUvs[ i ][ fi + 1 ].push( uv ); + + } + + } + + } + + if ( hasFaceNormal ) { + + normalIndex = faces[ offset ++ ] * 3; + + faceA.normal.set( + normals[ normalIndex ++ ], + normals[ normalIndex ++ ], + normals[ normalIndex ] + ); + + faceB.normal.copy( faceA.normal ); + + } + + if ( hasFaceVertexNormal ) { + + for ( i = 0; i < 4; i ++ ) { + + normalIndex = faces[ offset ++ ] * 3; + + normal = new Vector3( + normals[ normalIndex ++ ], + normals[ normalIndex ++ ], + normals[ normalIndex ] + ); + + + if ( i !== 2 ) faceA.vertexNormals.push( normal ); + if ( i !== 0 ) faceB.vertexNormals.push( normal ); + + } + + } + + + if ( hasFaceColor ) { + + colorIndex = faces[ offset ++ ]; + hex = colors[ colorIndex ]; + + faceA.color.setHex( hex ); + faceB.color.setHex( hex ); + + } + + + if ( hasFaceVertexColor ) { + + for ( i = 0; i < 4; i ++ ) { + + colorIndex = faces[ offset ++ ]; + hex = colors[ colorIndex ]; + + if ( i !== 2 ) faceA.vertexColors.push( new Color( hex ) ); + if ( i !== 0 ) faceB.vertexColors.push( new Color( hex ) ); + + } + + } + + geometry.faces.push( faceA ); + geometry.faces.push( faceB ); + + } else { + + face = new Face3(); + face.a = faces[ offset ++ ]; + face.b = faces[ offset ++ ]; + face.c = faces[ offset ++ ]; + + if ( hasMaterial ) { + + materialIndex = faces[ offset ++ ]; + face.materialIndex = materialIndex; + + } + + // to get face <=> uv index correspondence + + fi = geometry.faces.length; + + if ( hasFaceVertexUv ) { + + for ( i = 0; i < nUvLayers; i ++ ) { + + uvLayer = json.uvs[ i ]; + + geometry.faceVertexUvs[ i ][ fi ] = []; + + for ( j = 0; j < 3; j ++ ) { + + uvIndex = faces[ offset ++ ]; + + u = uvLayer[ uvIndex * 2 ]; + v = uvLayer[ uvIndex * 2 + 1 ]; + + uv = new Vector2( u, v ); + + geometry.faceVertexUvs[ i ][ fi ].push( uv ); + + } + + } + + } + + if ( hasFaceNormal ) { + + normalIndex = faces[ offset ++ ] * 3; + + face.normal.set( + normals[ normalIndex ++ ], + normals[ normalIndex ++ ], + normals[ normalIndex ] + ); + + } + + if ( hasFaceVertexNormal ) { + + for ( i = 0; i < 3; i ++ ) { + + normalIndex = faces[ offset ++ ] * 3; + + normal = new Vector3( + normals[ normalIndex ++ ], + normals[ normalIndex ++ ], + normals[ normalIndex ] + ); + + face.vertexNormals.push( normal ); + + } + + } + + + if ( hasFaceColor ) { + + colorIndex = faces[ offset ++ ]; + face.color.setHex( colors[ colorIndex ] ); + + } + + + if ( hasFaceVertexColor ) { + + for ( i = 0; i < 3; i ++ ) { + + colorIndex = faces[ offset ++ ]; + face.vertexColors.push( new Color( colors[ colorIndex ] ) ); + + } + + } + + geometry.faces.push( face ); + + } + + } + + } + + function parseSkin( json, geometry ) { + + var influencesPerVertex = ( json.influencesPerVertex !== undefined ) ? json.influencesPerVertex : 2; + + if ( json.skinWeights ) { + + for ( var i = 0, l = json.skinWeights.length; i < l; i += influencesPerVertex ) { + + var x = json.skinWeights[ i ]; + var y = ( influencesPerVertex > 1 ) ? json.skinWeights[ i + 1 ] : 0; + var z = ( influencesPerVertex > 2 ) ? json.skinWeights[ i + 2 ] : 0; + var w = ( influencesPerVertex > 3 ) ? json.skinWeights[ i + 3 ] : 0; + + geometry.skinWeights.push( new Vector4( x, y, z, w ) ); + + } + + } + + if ( json.skinIndices ) { + + for ( var i = 0, l = json.skinIndices.length; i < l; i += influencesPerVertex ) { + + var a = json.skinIndices[ i ]; + var b = ( influencesPerVertex > 1 ) ? json.skinIndices[ i + 1 ] : 0; + var c = ( influencesPerVertex > 2 ) ? json.skinIndices[ i + 2 ] : 0; + var d = ( influencesPerVertex > 3 ) ? json.skinIndices[ i + 3 ] : 0; + + geometry.skinIndices.push( new Vector4( a, b, c, d ) ); + + } + + } + + geometry.bones = json.bones; + + if ( geometry.bones && geometry.bones.length > 0 && ( geometry.skinWeights.length !== geometry.skinIndices.length || geometry.skinIndices.length !== geometry.vertices.length ) ) { + + console.warn( 'When skinning, number of vertices (' + geometry.vertices.length + '), skinIndices (' + + geometry.skinIndices.length + '), and skinWeights (' + geometry.skinWeights.length + ') should match.' ); + + } + + } + + function parseMorphing( json, geometry ) { + + var scale = json.scale; + + if ( json.morphTargets !== undefined ) { + + for ( var i = 0, l = json.morphTargets.length; i < l; i ++ ) { + + geometry.morphTargets[ i ] = {}; + geometry.morphTargets[ i ].name = json.morphTargets[ i ].name; + geometry.morphTargets[ i ].vertices = []; + + var dstVertices = geometry.morphTargets[ i ].vertices; + var srcVertices = json.morphTargets[ i ].vertices; + + for ( var v = 0, vl = srcVertices.length; v < vl; v += 3 ) { + + var vertex = new Vector3(); + vertex.x = srcVertices[ v ] * scale; + vertex.y = srcVertices[ v + 1 ] * scale; + vertex.z = srcVertices[ v + 2 ] * scale; + + dstVertices.push( vertex ); + + } + + } + + } + + if ( json.morphColors !== undefined && json.morphColors.length > 0 ) { + + console.warn( 'THREE.JSONLoader: "morphColors" no longer supported. Using them as face colors.' ); + + var faces = geometry.faces; + var morphColors = json.morphColors[ 0 ].colors; + + for ( var i = 0, l = faces.length; i < l; i ++ ) { + + faces[ i ].color.fromArray( morphColors, i * 3 ); + + } + + } + + } + + function parseAnimations( json, geometry ) { + + var outputAnimations = []; + + // parse old style Bone/Hierarchy animations + var animations = []; + + if ( json.animation !== undefined ) { + + animations.push( json.animation ); + + } + + if ( json.animations !== undefined ) { + + if ( json.animations.length ) { + + animations = animations.concat( json.animations ); + + } else { + + animations.push( json.animations ); + + } + + } + + for ( var i = 0; i < animations.length; i ++ ) { + + var clip = AnimationClip.parseAnimation( animations[ i ], geometry.bones ); + if ( clip ) outputAnimations.push( clip ); + + } + + // parse implicit morph animations + if ( geometry.morphTargets ) { + + // TODO: Figure out what an appropraite FPS is for morph target animations -- defaulting to 10, but really it is completely arbitrary. + var morphAnimationClips = AnimationClip.CreateClipsFromMorphTargetSequences( geometry.morphTargets, 10 ); + outputAnimations = outputAnimations.concat( morphAnimationClips ); + + } + + if ( outputAnimations.length > 0 ) geometry.animations = outputAnimations; + + } + + return function parse( json, texturePath ) { + + if ( json.data !== undefined ) { + + // Geometry 4.0 spec + json = json.data; + + } + + if ( json.scale !== undefined ) { + + json.scale = 1.0 / json.scale; + + } else { + + json.scale = 1.0; + + } + + var geometry = new Geometry(); + + parseModel( json, geometry ); + parseSkin( json, geometry ); + parseMorphing( json, geometry ); + parseAnimations( json, geometry ); + + geometry.computeFaceNormals(); + geometry.computeBoundingSphere(); + + if ( json.materials === undefined || json.materials.length === 0 ) { + + return { geometry: geometry }; + + } else { + + var materials = Loader.prototype.initMaterials( json.materials, texturePath, this.crossOrigin ); + + return { geometry: geometry, materials: materials }; + + } + + }; + + } )() + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function ObjectLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + this.texturePath = ''; + + } + + Object.assign( ObjectLoader.prototype, { + + load: function ( url, onLoad, onProgress, onError ) { + + if ( this.texturePath === '' ) { + + this.texturePath = url.substring( 0, url.lastIndexOf( '/' ) + 1 ); + + } + + var scope = this; + + var loader = new FileLoader( scope.manager ); + loader.load( url, function ( text ) { + + var json = null; + + try { + + json = JSON.parse( text ); + + } catch ( error ) { + + if ( onError !== undefined ) onError( error ); + + console.error( 'THREE:ObjectLoader: Can\'t parse ' + url + '.', error.message ); + + return; + + } + + var metadata = json.metadata; + + if ( metadata === undefined || metadata.type === undefined || metadata.type.toLowerCase() === 'geometry' ) { + + console.error( 'THREE.ObjectLoader: Can\'t load ' + url + '. Use THREE.JSONLoader instead.' ); + return; + + } + + scope.parse( json, onLoad ); + + }, onProgress, onError ); + + }, + + setTexturePath: function ( value ) { + + this.texturePath = value; + return this; + + }, + + setCrossOrigin: function ( value ) { + + this.crossOrigin = value; + return this; + + }, + + parse: function ( json, onLoad ) { + + var shapes = this.parseShape( json.shapes ); + var geometries = this.parseGeometries( json.geometries, shapes ); + + var images = this.parseImages( json.images, function () { + + if ( onLoad !== undefined ) onLoad( object ); + + } ); + + var textures = this.parseTextures( json.textures, images ); + var materials = this.parseMaterials( json.materials, textures ); + + var object = this.parseObject( json.object, geometries, materials ); + + if ( json.animations ) { + + object.animations = this.parseAnimations( json.animations ); + + } + + if ( json.images === undefined || json.images.length === 0 ) { + + if ( onLoad !== undefined ) onLoad( object ); + + } + + return object; + + }, + + parseShape: function ( json ) { + + var shapes = {}; + + if ( json !== undefined ) { + + for ( var i = 0, l = json.length; i < l; i ++ ) { + + var shape = new Shape().fromJSON( json[ i ] ); + + shapes[ shape.uuid ] = shape; + + } + + } + + return shapes; + + }, + + parseGeometries: function ( json, shapes ) { + + var geometries = {}; + + if ( json !== undefined ) { + + var geometryLoader = new JSONLoader(); + var bufferGeometryLoader = new BufferGeometryLoader(); + + for ( var i = 0, l = json.length; i < l; i ++ ) { + + var geometry; + var data = json[ i ]; + + switch ( data.type ) { + + case 'PlaneGeometry': + case 'PlaneBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.width, + data.height, + data.widthSegments, + data.heightSegments + ); + + break; + + case 'BoxGeometry': + case 'BoxBufferGeometry': + case 'CubeGeometry': // backwards compatible + + geometry = new Geometries[ data.type ]( + data.width, + data.height, + data.depth, + data.widthSegments, + data.heightSegments, + data.depthSegments + ); + + break; + + case 'CircleGeometry': + case 'CircleBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.radius, + data.segments, + data.thetaStart, + data.thetaLength + ); + + break; + + case 'CylinderGeometry': + case 'CylinderBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.radiusTop, + data.radiusBottom, + data.height, + data.radialSegments, + data.heightSegments, + data.openEnded, + data.thetaStart, + data.thetaLength + ); + + break; + + case 'ConeGeometry': + case 'ConeBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.radius, + data.height, + data.radialSegments, + data.heightSegments, + data.openEnded, + data.thetaStart, + data.thetaLength + ); + + break; + + case 'SphereGeometry': + case 'SphereBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.radius, + data.widthSegments, + data.heightSegments, + data.phiStart, + data.phiLength, + data.thetaStart, + data.thetaLength + ); + + break; + + case 'DodecahedronGeometry': + case 'DodecahedronBufferGeometry': + case 'IcosahedronGeometry': + case 'IcosahedronBufferGeometry': + case 'OctahedronGeometry': + case 'OctahedronBufferGeometry': + case 'TetrahedronGeometry': + case 'TetrahedronBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.radius, + data.detail + ); + + break; + + case 'RingGeometry': + case 'RingBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.innerRadius, + data.outerRadius, + data.thetaSegments, + data.phiSegments, + data.thetaStart, + data.thetaLength + ); + + break; + + case 'TorusGeometry': + case 'TorusBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.radius, + data.tube, + data.radialSegments, + data.tubularSegments, + data.arc + ); + + break; + + case 'TorusKnotGeometry': + case 'TorusKnotBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.radius, + data.tube, + data.tubularSegments, + data.radialSegments, + data.p, + data.q + ); + + break; + + case 'LatheGeometry': + case 'LatheBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.points, + data.segments, + data.phiStart, + data.phiLength + ); + + break; + + case 'PolyhedronGeometry': + case 'PolyhedronBufferGeometry': + + geometry = new Geometries[ data.type ]( + data.vertices, + data.indices, + data.radius, + data.details + ); + + break; + + case 'ShapeGeometry': + case 'ShapeBufferGeometry': + + var geometryShapes = []; + + for ( var j = 0, jl = data.shapes.length; j < jl; j ++ ) { + + var shape = shapes[ data.shapes[ j ] ]; + + geometryShapes.push( shape ); + + } + + geometry = new Geometries[ data.type ]( + geometryShapes, + data.curveSegments + ); + + break; + + + case 'ExtrudeGeometry': + case 'ExtrudeBufferGeometry': + + var geometryShapes = []; + + for ( var j = 0, jl = data.shapes.length; j < jl; j ++ ) { + + var shape = shapes[ data.shapes[ j ] ]; + + geometryShapes.push( shape ); + + } + + var extrudePath = data.options.extrudePath; + + if ( extrudePath !== undefined ) { + + data.options.extrudePath = new Curves[ extrudePath.type ]().fromJSON( extrudePath ); + + } + + geometry = new Geometries[ data.type ]( + geometryShapes, + data.options + ); + + break; + + case 'BufferGeometry': + + geometry = bufferGeometryLoader.parse( data ); + + break; + + case 'Geometry': + + geometry = geometryLoader.parse( data, this.texturePath ).geometry; + + break; + + default: + + console.warn( 'THREE.ObjectLoader: Unsupported geometry type "' + data.type + '"' ); + + continue; + + } + + geometry.uuid = data.uuid; + + if ( data.name !== undefined ) geometry.name = data.name; + if ( geometry.isBufferGeometry === true && data.userData !== undefined ) geometry.userData = data.userData; + + geometries[ data.uuid ] = geometry; + + } + + } + + return geometries; + + }, + + parseMaterials: function ( json, textures ) { + + var materials = {}; + + if ( json !== undefined ) { + + var loader = new MaterialLoader(); + loader.setTextures( textures ); + + for ( var i = 0, l = json.length; i < l; i ++ ) { + + var data = json[ i ]; + + if ( data.type === 'MultiMaterial' ) { + + // Deprecated + + var array = []; + + for ( var j = 0; j < data.materials.length; j ++ ) { + + array.push( loader.parse( data.materials[ j ] ) ); + + } + + materials[ data.uuid ] = array; + + } else { + + materials[ data.uuid ] = loader.parse( data ); + + } + + } + + } + + return materials; + + }, + + parseAnimations: function ( json ) { + + var animations = []; + + for ( var i = 0; i < json.length; i ++ ) { + + var data = json[ i ]; + + var clip = AnimationClip.parse( data ); + + if ( data.uuid !== undefined ) clip.uuid = data.uuid; + + animations.push( clip ); + + } + + return animations; + + }, + + parseImages: function ( json, onLoad ) { + + var scope = this; + var images = {}; + + function loadImage( url ) { + + scope.manager.itemStart( url ); + + return loader.load( url, function () { + + scope.manager.itemEnd( url ); + + }, undefined, function () { + + scope.manager.itemEnd( url ); + scope.manager.itemError( url ); + + } ); + + } + + if ( json !== undefined && json.length > 0 ) { + + var manager = new LoadingManager( onLoad ); + + var loader = new ImageLoader( manager ); + loader.setCrossOrigin( this.crossOrigin ); + + for ( var i = 0, l = json.length; i < l; i ++ ) { + + var image = json[ i ]; + var path = /^(\/\/)|([a-z]+:(\/\/)?)/i.test( image.url ) ? image.url : scope.texturePath + image.url; + + images[ image.uuid ] = loadImage( path ); + + } + + } + + return images; + + }, + + parseTextures: function ( json, images ) { + + function parseConstant( value, type ) { + + if ( typeof value === 'number' ) return value; + + console.warn( 'THREE.ObjectLoader.parseTexture: Constant should be in numeric form.', value ); + + return type[ value ]; + + } + + var textures = {}; + + if ( json !== undefined ) { + + for ( var i = 0, l = json.length; i < l; i ++ ) { + + var data = json[ i ]; + + if ( data.image === undefined ) { + + console.warn( 'THREE.ObjectLoader: No "image" specified for', data.uuid ); + + } + + if ( images[ data.image ] === undefined ) { + + console.warn( 'THREE.ObjectLoader: Undefined image', data.image ); + + } + + var texture = new Texture( images[ data.image ] ); + texture.needsUpdate = true; + + texture.uuid = data.uuid; + + if ( data.name !== undefined ) texture.name = data.name; + + if ( data.mapping !== undefined ) texture.mapping = parseConstant( data.mapping, TEXTURE_MAPPING ); + + if ( data.offset !== undefined ) texture.offset.fromArray( data.offset ); + if ( data.repeat !== undefined ) texture.repeat.fromArray( data.repeat ); + if ( data.center !== undefined ) texture.center.fromArray( data.center ); + if ( data.rotation !== undefined ) texture.rotation = data.rotation; + + if ( data.wrap !== undefined ) { + + texture.wrapS = parseConstant( data.wrap[ 0 ], TEXTURE_WRAPPING ); + texture.wrapT = parseConstant( data.wrap[ 1 ], TEXTURE_WRAPPING ); + + } + + if ( data.format !== undefined ) texture.format = data.format; + + if ( data.minFilter !== undefined ) texture.minFilter = parseConstant( data.minFilter, TEXTURE_FILTER ); + if ( data.magFilter !== undefined ) texture.magFilter = parseConstant( data.magFilter, TEXTURE_FILTER ); + if ( data.anisotropy !== undefined ) texture.anisotropy = data.anisotropy; + + if ( data.flipY !== undefined ) texture.flipY = data.flipY; + + textures[ data.uuid ] = texture; + + } + + } + + return textures; + + }, + + parseObject: function ( data, geometries, materials ) { + + var object; + + function getGeometry( name ) { + + if ( geometries[ name ] === undefined ) { + + console.warn( 'THREE.ObjectLoader: Undefined geometry', name ); + + } + + return geometries[ name ]; + + } + + function getMaterial( name ) { + + if ( name === undefined ) return undefined; + + if ( Array.isArray( name ) ) { + + var array = []; + + for ( var i = 0, l = name.length; i < l; i ++ ) { + + var uuid = name[ i ]; + + if ( materials[ uuid ] === undefined ) { + + console.warn( 'THREE.ObjectLoader: Undefined material', uuid ); + + } + + array.push( materials[ uuid ] ); + + } + + return array; + + } + + if ( materials[ name ] === undefined ) { + + console.warn( 'THREE.ObjectLoader: Undefined material', name ); + + } + + return materials[ name ]; + + } + + switch ( data.type ) { + + case 'Scene': + + object = new Scene(); + + if ( data.background !== undefined ) { + + if ( Number.isInteger( data.background ) ) { + + object.background = new Color( data.background ); + + } + + } + + if ( data.fog !== undefined ) { + + if ( data.fog.type === 'Fog' ) { + + object.fog = new Fog( data.fog.color, data.fog.near, data.fog.far ); + + } else if ( data.fog.type === 'FogExp2' ) { + + object.fog = new FogExp2( data.fog.color, data.fog.density ); + + } + + } + + break; + + case 'PerspectiveCamera': + + object = new PerspectiveCamera( data.fov, data.aspect, data.near, data.far ); + + if ( data.focus !== undefined ) object.focus = data.focus; + if ( data.zoom !== undefined ) object.zoom = data.zoom; + if ( data.filmGauge !== undefined ) object.filmGauge = data.filmGauge; + if ( data.filmOffset !== undefined ) object.filmOffset = data.filmOffset; + if ( data.view !== undefined ) object.view = Object.assign( {}, data.view ); + + break; + + case 'OrthographicCamera': + + object = new OrthographicCamera( data.left, data.right, data.top, data.bottom, data.near, data.far ); + + if ( data.zoom !== undefined ) object.zoom = data.zoom; + if ( data.view !== undefined ) object.view = Object.assign( {}, data.view ); + + break; + + case 'AmbientLight': + + object = new AmbientLight( data.color, data.intensity ); + + break; + + case 'DirectionalLight': + + object = new DirectionalLight( data.color, data.intensity ); + + break; + + case 'PointLight': + + object = new PointLight( data.color, data.intensity, data.distance, data.decay ); + + break; + + case 'RectAreaLight': + + object = new RectAreaLight( data.color, data.intensity, data.width, data.height ); + + break; + + case 'SpotLight': + + object = new SpotLight( data.color, data.intensity, data.distance, data.angle, data.penumbra, data.decay ); + + break; + + case 'HemisphereLight': + + object = new HemisphereLight( data.color, data.groundColor, data.intensity ); + + break; + + case 'SkinnedMesh': + + console.warn( 'THREE.ObjectLoader.parseObject() does not support SkinnedMesh yet.' ); + + case 'Mesh': + + var geometry = getGeometry( data.geometry ); + var material = getMaterial( data.material ); + + if ( geometry.bones && geometry.bones.length > 0 ) { + + object = new SkinnedMesh( geometry, material ); + + } else { + + object = new Mesh( geometry, material ); + + } + + break; + + case 'LOD': + + object = new LOD(); + + break; + + case 'Line': + + object = new Line( getGeometry( data.geometry ), getMaterial( data.material ), data.mode ); + + break; + + case 'LineLoop': + + object = new LineLoop( getGeometry( data.geometry ), getMaterial( data.material ) ); + + break; + + case 'LineSegments': + + object = new LineSegments( getGeometry( data.geometry ), getMaterial( data.material ) ); + + break; + + case 'PointCloud': + case 'Points': + + object = new Points( getGeometry( data.geometry ), getMaterial( data.material ) ); + + break; + + case 'Sprite': + + object = new Sprite( getMaterial( data.material ) ); + + break; + + case 'Group': + + object = new Group(); + + break; + + default: + + object = new Object3D(); + + } + + object.uuid = data.uuid; + + if ( data.name !== undefined ) object.name = data.name; + + if ( data.matrix !== undefined ) { + + object.matrix.fromArray( data.matrix ); + + if ( data.matrixAutoUpdate !== undefined ) object.matrixAutoUpdate = data.matrixAutoUpdate; + if ( object.matrixAutoUpdate ) object.matrix.decompose( object.position, object.quaternion, object.scale ); + + } else { + + if ( data.position !== undefined ) object.position.fromArray( data.position ); + if ( data.rotation !== undefined ) object.rotation.fromArray( data.rotation ); + if ( data.quaternion !== undefined ) object.quaternion.fromArray( data.quaternion ); + if ( data.scale !== undefined ) object.scale.fromArray( data.scale ); + + } + + if ( data.castShadow !== undefined ) object.castShadow = data.castShadow; + if ( data.receiveShadow !== undefined ) object.receiveShadow = data.receiveShadow; + + if ( data.shadow ) { + + if ( data.shadow.bias !== undefined ) object.shadow.bias = data.shadow.bias; + if ( data.shadow.radius !== undefined ) object.shadow.radius = data.shadow.radius; + if ( data.shadow.mapSize !== undefined ) object.shadow.mapSize.fromArray( data.shadow.mapSize ); + if ( data.shadow.camera !== undefined ) object.shadow.camera = this.parseObject( data.shadow.camera ); + + } + + if ( data.visible !== undefined ) object.visible = data.visible; + if ( data.frustumCulled !== undefined ) object.frustumCulled = data.frustumCulled; + if ( data.renderOrder !== undefined ) object.renderOrder = data.renderOrder; + if ( data.userData !== undefined ) object.userData = data.userData; + + if ( data.children !== undefined ) { + + var children = data.children; + + for ( var i = 0; i < children.length; i ++ ) { + + object.add( this.parseObject( children[ i ], geometries, materials ) ); + + } + + } + + if ( data.type === 'LOD' ) { + + var levels = data.levels; + + for ( var l = 0; l < levels.length; l ++ ) { + + var level = levels[ l ]; + var child = object.getObjectByProperty( 'uuid', level.object ); + + if ( child !== undefined ) { + + object.addLevel( child, level.distance ); + + } + + } + + } + + return object; + + } + + } ); + + var TEXTURE_MAPPING = { + UVMapping: UVMapping, + CubeReflectionMapping: CubeReflectionMapping, + CubeRefractionMapping: CubeRefractionMapping, + EquirectangularReflectionMapping: EquirectangularReflectionMapping, + EquirectangularRefractionMapping: EquirectangularRefractionMapping, + SphericalReflectionMapping: SphericalReflectionMapping, + CubeUVReflectionMapping: CubeUVReflectionMapping, + CubeUVRefractionMapping: CubeUVRefractionMapping + }; + + var TEXTURE_WRAPPING = { + RepeatWrapping: RepeatWrapping, + ClampToEdgeWrapping: ClampToEdgeWrapping, + MirroredRepeatWrapping: MirroredRepeatWrapping + }; + + var TEXTURE_FILTER = { + NearestFilter: NearestFilter, + NearestMipMapNearestFilter: NearestMipMapNearestFilter, + NearestMipMapLinearFilter: NearestMipMapLinearFilter, + LinearFilter: LinearFilter, + LinearMipMapNearestFilter: LinearMipMapNearestFilter, + LinearMipMapLinearFilter: LinearMipMapLinearFilter + }; + + /** + * @author zz85 / http://www.lab4games.net/zz85/blog + * minimal class for proxing functions to Path. Replaces old "extractSubpaths()" + **/ + + function ShapePath() { + + this.type = 'ShapePath'; + + this.color = new Color(); + + this.subPaths = []; + this.currentPath = null; + + } + + Object.assign( ShapePath.prototype, { + + moveTo: function ( x, y ) { + + this.currentPath = new Path(); + this.subPaths.push( this.currentPath ); + this.currentPath.moveTo( x, y ); + + }, + + lineTo: function ( x, y ) { + + this.currentPath.lineTo( x, y ); + + }, + + quadraticCurveTo: function ( aCPx, aCPy, aX, aY ) { + + this.currentPath.quadraticCurveTo( aCPx, aCPy, aX, aY ); + + }, + + bezierCurveTo: function ( aCP1x, aCP1y, aCP2x, aCP2y, aX, aY ) { + + this.currentPath.bezierCurveTo( aCP1x, aCP1y, aCP2x, aCP2y, aX, aY ); + + }, + + splineThru: function ( pts ) { + + this.currentPath.splineThru( pts ); + + }, + + toShapes: function ( isCCW, noHoles ) { + + function toShapesNoHoles( inSubpaths ) { + + var shapes = []; + + for ( var i = 0, l = inSubpaths.length; i < l; i ++ ) { + + var tmpPath = inSubpaths[ i ]; + + var tmpShape = new Shape(); + tmpShape.curves = tmpPath.curves; + + shapes.push( tmpShape ); + + } + + return shapes; + + } + + function isPointInsidePolygon( inPt, inPolygon ) { + + var polyLen = inPolygon.length; + + // inPt on polygon contour => immediate success or + // toggling of inside/outside at every single! intersection point of an edge + // with the horizontal line through inPt, left of inPt + // not counting lowerY endpoints of edges and whole edges on that line + var inside = false; + for ( var p = polyLen - 1, q = 0; q < polyLen; p = q ++ ) { + + var edgeLowPt = inPolygon[ p ]; + var edgeHighPt = inPolygon[ q ]; + + var edgeDx = edgeHighPt.x - edgeLowPt.x; + var edgeDy = edgeHighPt.y - edgeLowPt.y; + + if ( Math.abs( edgeDy ) > Number.EPSILON ) { + + // not parallel + if ( edgeDy < 0 ) { + + edgeLowPt = inPolygon[ q ]; edgeDx = - edgeDx; + edgeHighPt = inPolygon[ p ]; edgeDy = - edgeDy; + + } + if ( ( inPt.y < edgeLowPt.y ) || ( inPt.y > edgeHighPt.y ) ) continue; + + if ( inPt.y === edgeLowPt.y ) { + + if ( inPt.x === edgeLowPt.x ) return true; // inPt is on contour ? + // continue; // no intersection or edgeLowPt => doesn't count !!! + + } else { + + var perpEdge = edgeDy * ( inPt.x - edgeLowPt.x ) - edgeDx * ( inPt.y - edgeLowPt.y ); + if ( perpEdge === 0 ) return true; // inPt is on contour ? + if ( perpEdge < 0 ) continue; + inside = ! inside; // true intersection left of inPt + + } + + } else { + + // parallel or collinear + if ( inPt.y !== edgeLowPt.y ) continue; // parallel + // edge lies on the same horizontal line as inPt + if ( ( ( edgeHighPt.x <= inPt.x ) && ( inPt.x <= edgeLowPt.x ) ) || + ( ( edgeLowPt.x <= inPt.x ) && ( inPt.x <= edgeHighPt.x ) ) ) return true; // inPt: Point on contour ! + // continue; + + } + + } + + return inside; + + } + + var isClockWise = ShapeUtils.isClockWise; + + var subPaths = this.subPaths; + if ( subPaths.length === 0 ) return []; + + if ( noHoles === true ) return toShapesNoHoles( subPaths ); + + + var solid, tmpPath, tmpShape, shapes = []; + + if ( subPaths.length === 1 ) { + + tmpPath = subPaths[ 0 ]; + tmpShape = new Shape(); + tmpShape.curves = tmpPath.curves; + shapes.push( tmpShape ); + return shapes; + + } + + var holesFirst = ! isClockWise( subPaths[ 0 ].getPoints() ); + holesFirst = isCCW ? ! holesFirst : holesFirst; + + // console.log("Holes first", holesFirst); + + var betterShapeHoles = []; + var newShapes = []; + var newShapeHoles = []; + var mainIdx = 0; + var tmpPoints; + + newShapes[ mainIdx ] = undefined; + newShapeHoles[ mainIdx ] = []; + + for ( var i = 0, l = subPaths.length; i < l; i ++ ) { + + tmpPath = subPaths[ i ]; + tmpPoints = tmpPath.getPoints(); + solid = isClockWise( tmpPoints ); + solid = isCCW ? ! solid : solid; + + if ( solid ) { + + if ( ( ! holesFirst ) && ( newShapes[ mainIdx ] ) ) mainIdx ++; + + newShapes[ mainIdx ] = { s: new Shape(), p: tmpPoints }; + newShapes[ mainIdx ].s.curves = tmpPath.curves; + + if ( holesFirst ) mainIdx ++; + newShapeHoles[ mainIdx ] = []; + + //console.log('cw', i); + + } else { + + newShapeHoles[ mainIdx ].push( { h: tmpPath, p: tmpPoints[ 0 ] } ); + + //console.log('ccw', i); + + } + + } + + // only Holes? -> probably all Shapes with wrong orientation + if ( ! newShapes[ 0 ] ) return toShapesNoHoles( subPaths ); + + + if ( newShapes.length > 1 ) { + + var ambiguous = false; + var toChange = []; + + for ( var sIdx = 0, sLen = newShapes.length; sIdx < sLen; sIdx ++ ) { + + betterShapeHoles[ sIdx ] = []; + + } + + for ( var sIdx = 0, sLen = newShapes.length; sIdx < sLen; sIdx ++ ) { + + var sho = newShapeHoles[ sIdx ]; + + for ( var hIdx = 0; hIdx < sho.length; hIdx ++ ) { + + var ho = sho[ hIdx ]; + var hole_unassigned = true; + + for ( var s2Idx = 0; s2Idx < newShapes.length; s2Idx ++ ) { + + if ( isPointInsidePolygon( ho.p, newShapes[ s2Idx ].p ) ) { + + if ( sIdx !== s2Idx ) toChange.push( { froms: sIdx, tos: s2Idx, hole: hIdx } ); + if ( hole_unassigned ) { + + hole_unassigned = false; + betterShapeHoles[ s2Idx ].push( ho ); + + } else { + + ambiguous = true; + + } + + } + + } + if ( hole_unassigned ) { + + betterShapeHoles[ sIdx ].push( ho ); + + } + + } + + } + // console.log("ambiguous: ", ambiguous); + if ( toChange.length > 0 ) { + + // console.log("to change: ", toChange); + if ( ! ambiguous ) newShapeHoles = betterShapeHoles; + + } + + } + + var tmpHoles; + + for ( var i = 0, il = newShapes.length; i < il; i ++ ) { + + tmpShape = newShapes[ i ].s; + shapes.push( tmpShape ); + tmpHoles = newShapeHoles[ i ]; + + for ( var j = 0, jl = tmpHoles.length; j < jl; j ++ ) { + + tmpShape.holes.push( tmpHoles[ j ].h ); + + } + + } + + //console.log("shape", shapes); + + return shapes; + + } + + } ); + + /** + * @author zz85 / http://www.lab4games.net/zz85/blog + * @author mrdoob / http://mrdoob.com/ + */ + + + function Font( data ) { + + this.type = 'Font'; + + this.data = data; + + } + + Object.assign( Font.prototype, { + + isFont: true, + + generateShapes: function ( text, size, divisions ) { + + if ( size === undefined ) size = 100; + if ( divisions === undefined ) divisions = 4; + + var shapes = []; + var paths = createPaths( text, size, divisions, this.data ); + + for ( var p = 0, pl = paths.length; p < pl; p ++ ) { + + Array.prototype.push.apply( shapes, paths[ p ].toShapes() ); + + } + + return shapes; + + } + + } ); + + function createPaths( text, size, divisions, data ) { + + var chars = Array.from ? Array.from( text ) : String( text ).split( '' ); // see #13988 + var scale = size / data.resolution; + var line_height = ( data.boundingBox.yMax - data.boundingBox.yMin + data.underlineThickness ) * scale; + + var paths = []; + + var offsetX = 0, offsetY = 0; + + for ( var i = 0; i < chars.length; i ++ ) { + + var char = chars[ i ]; + + if ( char === '\n' ) { + + offsetX = 0; + offsetY -= line_height; + + } else { + + var ret = createPath( char, divisions, scale, offsetX, offsetY, data ); + offsetX += ret.offsetX; + paths.push( ret.path ); + + } + + } + + return paths; + + } + + function createPath( char, divisions, scale, offsetX, offsetY, data ) { + + var glyph = data.glyphs[ char ] || data.glyphs[ '?' ]; + + if ( ! glyph ) return; + + var path = new ShapePath(); + + var x, y, cpx, cpy, cpx1, cpy1, cpx2, cpy2; + + if ( glyph.o ) { + + var outline = glyph._cachedOutline || ( glyph._cachedOutline = glyph.o.split( ' ' ) ); + + for ( var i = 0, l = outline.length; i < l; ) { + + var action = outline[ i ++ ]; + + switch ( action ) { + + case 'm': // moveTo + + x = outline[ i ++ ] * scale + offsetX; + y = outline[ i ++ ] * scale + offsetY; + + path.moveTo( x, y ); + + break; + + case 'l': // lineTo + + x = outline[ i ++ ] * scale + offsetX; + y = outline[ i ++ ] * scale + offsetY; + + path.lineTo( x, y ); + + break; + + case 'q': // quadraticCurveTo + + cpx = outline[ i ++ ] * scale + offsetX; + cpy = outline[ i ++ ] * scale + offsetY; + cpx1 = outline[ i ++ ] * scale + offsetX; + cpy1 = outline[ i ++ ] * scale + offsetY; + + path.quadraticCurveTo( cpx1, cpy1, cpx, cpy ); + + break; + + case 'b': // bezierCurveTo + + cpx = outline[ i ++ ] * scale + offsetX; + cpy = outline[ i ++ ] * scale + offsetY; + cpx1 = outline[ i ++ ] * scale + offsetX; + cpy1 = outline[ i ++ ] * scale + offsetY; + cpx2 = outline[ i ++ ] * scale + offsetX; + cpy2 = outline[ i ++ ] * scale + offsetY; + + path.bezierCurveTo( cpx1, cpy1, cpx2, cpy2, cpx, cpy ); + + break; + + } + + } + + } + + return { offsetX: glyph.ha * scale, path: path }; + + } + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function FontLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + + } + + Object.assign( FontLoader.prototype, { + + load: function ( url, onLoad, onProgress, onError ) { + + var scope = this; + + var loader = new FileLoader( this.manager ); + loader.setPath( this.path ); + loader.load( url, function ( text ) { + + var json; + + try { + + json = JSON.parse( text ); + + } catch ( e ) { + + console.warn( 'THREE.FontLoader: typeface.js support is being deprecated. Use typeface.json instead.' ); + json = JSON.parse( text.substring( 65, text.length - 2 ) ); + + } + + var font = scope.parse( json ); + + if ( onLoad ) onLoad( font ); + + }, onProgress, onError ); + + }, + + parse: function ( json ) { + + return new Font( json ); + + }, + + setPath: function ( value ) { + + this.path = value; + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + var context; + + var AudioContext = { + + getContext: function () { + + if ( context === undefined ) { + + context = new ( window.AudioContext || window.webkitAudioContext )(); + + } + + return context; + + }, + + setContext: function ( value ) { + + context = value; + + } + + }; + + /** + * @author Reece Aaron Lecrivain / http://reecenotes.com/ + */ + + function AudioLoader( manager ) { + + this.manager = ( manager !== undefined ) ? manager : DefaultLoadingManager; + + } + + Object.assign( AudioLoader.prototype, { + + load: function ( url, onLoad, onProgress, onError ) { + + var loader = new FileLoader( this.manager ); + loader.setResponseType( 'arraybuffer' ); + loader.load( url, function ( buffer ) { + + var context = AudioContext.getContext(); + + context.decodeAudioData( buffer, function ( audioBuffer ) { + + onLoad( audioBuffer ); + + } ); + + }, onProgress, onError ); + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function StereoCamera() { + + this.type = 'StereoCamera'; + + this.aspect = 1; + + this.eyeSep = 0.064; + + this.cameraL = new PerspectiveCamera(); + this.cameraL.layers.enable( 1 ); + this.cameraL.matrixAutoUpdate = false; + + this.cameraR = new PerspectiveCamera(); + this.cameraR.layers.enable( 2 ); + this.cameraR.matrixAutoUpdate = false; + + } + + Object.assign( StereoCamera.prototype, { + + update: ( function () { + + var instance, focus, fov, aspect, near, far, zoom, eyeSep; + + var eyeRight = new Matrix4(); + var eyeLeft = new Matrix4(); + + return function update( camera ) { + + var needsUpdate = instance !== this || focus !== camera.focus || fov !== camera.fov || + aspect !== camera.aspect * this.aspect || near !== camera.near || + far !== camera.far || zoom !== camera.zoom || eyeSep !== this.eyeSep; + + if ( needsUpdate ) { + + instance = this; + focus = camera.focus; + fov = camera.fov; + aspect = camera.aspect * this.aspect; + near = camera.near; + far = camera.far; + zoom = camera.zoom; + + // Off-axis stereoscopic effect based on + // http://paulbourke.net/stereographics/stereorender/ + + var projectionMatrix = camera.projectionMatrix.clone(); + eyeSep = this.eyeSep / 2; + var eyeSepOnProjection = eyeSep * near / focus; + var ymax = ( near * Math.tan( _Math.DEG2RAD * fov * 0.5 ) ) / zoom; + var xmin, xmax; + + // translate xOffset + + eyeLeft.elements[ 12 ] = - eyeSep; + eyeRight.elements[ 12 ] = eyeSep; + + // for left eye + + xmin = - ymax * aspect + eyeSepOnProjection; + xmax = ymax * aspect + eyeSepOnProjection; + + projectionMatrix.elements[ 0 ] = 2 * near / ( xmax - xmin ); + projectionMatrix.elements[ 8 ] = ( xmax + xmin ) / ( xmax - xmin ); + + this.cameraL.projectionMatrix.copy( projectionMatrix ); + + // for right eye + + xmin = - ymax * aspect - eyeSepOnProjection; + xmax = ymax * aspect - eyeSepOnProjection; + + projectionMatrix.elements[ 0 ] = 2 * near / ( xmax - xmin ); + projectionMatrix.elements[ 8 ] = ( xmax + xmin ) / ( xmax - xmin ); + + this.cameraR.projectionMatrix.copy( projectionMatrix ); + + } + + this.cameraL.matrixWorld.copy( camera.matrixWorld ).multiply( eyeLeft ); + this.cameraR.matrixWorld.copy( camera.matrixWorld ).multiply( eyeRight ); + + }; + + } )() + + } ); + + /** + * Camera for rendering cube maps + * - renders scene into axis-aligned cube + * + * @author alteredq / http://alteredqualia.com/ + */ + + function CubeCamera( near, far, cubeResolution ) { + + Object3D.call( this ); + + this.type = 'CubeCamera'; + + var fov = 90, aspect = 1; + + var cameraPX = new PerspectiveCamera( fov, aspect, near, far ); + cameraPX.up.set( 0, - 1, 0 ); + cameraPX.lookAt( new Vector3( 1, 0, 0 ) ); + this.add( cameraPX ); + + var cameraNX = new PerspectiveCamera( fov, aspect, near, far ); + cameraNX.up.set( 0, - 1, 0 ); + cameraNX.lookAt( new Vector3( - 1, 0, 0 ) ); + this.add( cameraNX ); + + var cameraPY = new PerspectiveCamera( fov, aspect, near, far ); + cameraPY.up.set( 0, 0, 1 ); + cameraPY.lookAt( new Vector3( 0, 1, 0 ) ); + this.add( cameraPY ); + + var cameraNY = new PerspectiveCamera( fov, aspect, near, far ); + cameraNY.up.set( 0, 0, - 1 ); + cameraNY.lookAt( new Vector3( 0, - 1, 0 ) ); + this.add( cameraNY ); + + var cameraPZ = new PerspectiveCamera( fov, aspect, near, far ); + cameraPZ.up.set( 0, - 1, 0 ); + cameraPZ.lookAt( new Vector3( 0, 0, 1 ) ); + this.add( cameraPZ ); + + var cameraNZ = new PerspectiveCamera( fov, aspect, near, far ); + cameraNZ.up.set( 0, - 1, 0 ); + cameraNZ.lookAt( new Vector3( 0, 0, - 1 ) ); + this.add( cameraNZ ); + + var options = { format: RGBFormat, magFilter: LinearFilter, minFilter: LinearFilter }; + + this.renderTarget = new WebGLRenderTargetCube( cubeResolution, cubeResolution, options ); + this.renderTarget.texture.name = "CubeCamera"; + + this.update = function ( renderer, scene ) { + + if ( this.parent === null ) this.updateMatrixWorld(); + + var renderTarget = this.renderTarget; + var generateMipmaps = renderTarget.texture.generateMipmaps; + + renderTarget.texture.generateMipmaps = false; + + renderTarget.activeCubeFace = 0; + renderer.render( scene, cameraPX, renderTarget ); + + renderTarget.activeCubeFace = 1; + renderer.render( scene, cameraNX, renderTarget ); + + renderTarget.activeCubeFace = 2; + renderer.render( scene, cameraPY, renderTarget ); + + renderTarget.activeCubeFace = 3; + renderer.render( scene, cameraNY, renderTarget ); + + renderTarget.activeCubeFace = 4; + renderer.render( scene, cameraPZ, renderTarget ); + + renderTarget.texture.generateMipmaps = generateMipmaps; + + renderTarget.activeCubeFace = 5; + renderer.render( scene, cameraNZ, renderTarget ); + + renderer.setRenderTarget( null ); + + }; + + this.clear = function ( renderer, color, depth, stencil ) { + + var renderTarget = this.renderTarget; + + for ( var i = 0; i < 6; i ++ ) { + + renderTarget.activeCubeFace = i; + renderer.setRenderTarget( renderTarget ); + + renderer.clear( color, depth, stencil ); + + } + + renderer.setRenderTarget( null ); + + }; + + } + + CubeCamera.prototype = Object.create( Object3D.prototype ); + CubeCamera.prototype.constructor = CubeCamera; + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function AudioListener() { + + Object3D.call( this ); + + this.type = 'AudioListener'; + + this.context = AudioContext.getContext(); + + this.gain = this.context.createGain(); + this.gain.connect( this.context.destination ); + + this.filter = null; + + } + + AudioListener.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: AudioListener, + + getInput: function () { + + return this.gain; + + }, + + removeFilter: function ( ) { + + if ( this.filter !== null ) { + + this.gain.disconnect( this.filter ); + this.filter.disconnect( this.context.destination ); + this.gain.connect( this.context.destination ); + this.filter = null; + + } + + }, + + getFilter: function () { + + return this.filter; + + }, + + setFilter: function ( value ) { + + if ( this.filter !== null ) { + + this.gain.disconnect( this.filter ); + this.filter.disconnect( this.context.destination ); + + } else { + + this.gain.disconnect( this.context.destination ); + + } + + this.filter = value; + this.gain.connect( this.filter ); + this.filter.connect( this.context.destination ); + + }, + + getMasterVolume: function () { + + return this.gain.gain.value; + + }, + + setMasterVolume: function ( value ) { + + this.gain.gain.setTargetAtTime( value, this.context.currentTime, 0.01 ); + + }, + + updateMatrixWorld: ( function () { + + var position = new Vector3(); + var quaternion = new Quaternion(); + var scale = new Vector3(); + + var orientation = new Vector3(); + + return function updateMatrixWorld( force ) { + + Object3D.prototype.updateMatrixWorld.call( this, force ); + + var listener = this.context.listener; + var up = this.up; + + this.matrixWorld.decompose( position, quaternion, scale ); + + orientation.set( 0, 0, - 1 ).applyQuaternion( quaternion ); + + if ( listener.positionX ) { + + listener.positionX.setValueAtTime( position.x, this.context.currentTime ); + listener.positionY.setValueAtTime( position.y, this.context.currentTime ); + listener.positionZ.setValueAtTime( position.z, this.context.currentTime ); + listener.forwardX.setValueAtTime( orientation.x, this.context.currentTime ); + listener.forwardY.setValueAtTime( orientation.y, this.context.currentTime ); + listener.forwardZ.setValueAtTime( orientation.z, this.context.currentTime ); + listener.upX.setValueAtTime( up.x, this.context.currentTime ); + listener.upY.setValueAtTime( up.y, this.context.currentTime ); + listener.upZ.setValueAtTime( up.z, this.context.currentTime ); + + } else { + + listener.setPosition( position.x, position.y, position.z ); + listener.setOrientation( orientation.x, orientation.y, orientation.z, up.x, up.y, up.z ); + + } + + }; + + } )() + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author Reece Aaron Lecrivain / http://reecenotes.com/ + */ + + function Audio( listener ) { + + Object3D.call( this ); + + this.type = 'Audio'; + + this.context = listener.context; + + this.gain = this.context.createGain(); + this.gain.connect( listener.getInput() ); + + this.autoplay = false; + + this.buffer = null; + this.loop = false; + this.startTime = 0; + this.offset = 0; + this.playbackRate = 1; + this.isPlaying = false; + this.hasPlaybackControl = true; + this.sourceType = 'empty'; + + this.filters = []; + + } + + Audio.prototype = Object.assign( Object.create( Object3D.prototype ), { + + constructor: Audio, + + getOutput: function () { + + return this.gain; + + }, + + setNodeSource: function ( audioNode ) { + + this.hasPlaybackControl = false; + this.sourceType = 'audioNode'; + this.source = audioNode; + this.connect(); + + return this; + + }, + + setMediaElementSource: function ( mediaElement ) { + + this.hasPlaybackControl = false; + this.sourceType = 'mediaNode'; + this.source = this.context.createMediaElementSource( mediaElement ); + this.connect(); + + return this; + + }, + + setBuffer: function ( audioBuffer ) { + + this.buffer = audioBuffer; + this.sourceType = 'buffer'; + + if ( this.autoplay ) this.play(); + + return this; + + }, + + play: function () { + + if ( this.isPlaying === true ) { + + console.warn( 'THREE.Audio: Audio is already playing.' ); + return; + + } + + if ( this.hasPlaybackControl === false ) { + + console.warn( 'THREE.Audio: this Audio has no playback control.' ); + return; + + } + + var source = this.context.createBufferSource(); + + source.buffer = this.buffer; + source.loop = this.loop; + source.onended = this.onEnded.bind( this ); + source.playbackRate.setValueAtTime( this.playbackRate, this.startTime ); + this.startTime = this.context.currentTime; + source.start( this.startTime, this.offset ); + + this.isPlaying = true; + + this.source = source; + + return this.connect(); + + }, + + pause: function () { + + if ( this.hasPlaybackControl === false ) { + + console.warn( 'THREE.Audio: this Audio has no playback control.' ); + return; + + } + + if ( this.isPlaying === true ) { + + this.source.stop(); + this.offset += ( this.context.currentTime - this.startTime ) * this.playbackRate; + this.isPlaying = false; + + } + + return this; + + }, + + stop: function () { + + if ( this.hasPlaybackControl === false ) { + + console.warn( 'THREE.Audio: this Audio has no playback control.' ); + return; + + } + + this.source.stop(); + this.offset = 0; + this.isPlaying = false; + + return this; + + }, + + connect: function () { + + if ( this.filters.length > 0 ) { + + this.source.connect( this.filters[ 0 ] ); + + for ( var i = 1, l = this.filters.length; i < l; i ++ ) { + + this.filters[ i - 1 ].connect( this.filters[ i ] ); + + } + + this.filters[ this.filters.length - 1 ].connect( this.getOutput() ); + + } else { + + this.source.connect( this.getOutput() ); + + } + + return this; + + }, + + disconnect: function () { + + if ( this.filters.length > 0 ) { + + this.source.disconnect( this.filters[ 0 ] ); + + for ( var i = 1, l = this.filters.length; i < l; i ++ ) { + + this.filters[ i - 1 ].disconnect( this.filters[ i ] ); + + } + + this.filters[ this.filters.length - 1 ].disconnect( this.getOutput() ); + + } else { + + this.source.disconnect( this.getOutput() ); + + } + + return this; + + }, + + getFilters: function () { + + return this.filters; + + }, + + setFilters: function ( value ) { + + if ( ! value ) value = []; + + if ( this.isPlaying === true ) { + + this.disconnect(); + this.filters = value; + this.connect(); + + } else { + + this.filters = value; + + } + + return this; + + }, + + getFilter: function () { + + return this.getFilters()[ 0 ]; + + }, + + setFilter: function ( filter ) { + + return this.setFilters( filter ? [ filter ] : [] ); + + }, + + setPlaybackRate: function ( value ) { + + if ( this.hasPlaybackControl === false ) { + + console.warn( 'THREE.Audio: this Audio has no playback control.' ); + return; + + } + + this.playbackRate = value; + + if ( this.isPlaying === true ) { + + this.source.playbackRate.setValueAtTime( this.playbackRate, this.context.currentTime ); + + } + + return this; + + }, + + getPlaybackRate: function () { + + return this.playbackRate; + + }, + + onEnded: function () { + + this.isPlaying = false; + + }, + + getLoop: function () { + + if ( this.hasPlaybackControl === false ) { + + console.warn( 'THREE.Audio: this Audio has no playback control.' ); + return false; + + } + + return this.loop; + + }, + + setLoop: function ( value ) { + + if ( this.hasPlaybackControl === false ) { + + console.warn( 'THREE.Audio: this Audio has no playback control.' ); + return; + + } + + this.loop = value; + + if ( this.isPlaying === true ) { + + this.source.loop = this.loop; + + } + + return this; + + }, + + getVolume: function () { + + return this.gain.gain.value; + + }, + + setVolume: function ( value ) { + + this.gain.gain.setTargetAtTime( value, this.context.currentTime, 0.01 ); + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function PositionalAudio( listener ) { + + Audio.call( this, listener ); + + this.panner = this.context.createPanner(); + this.panner.connect( this.gain ); + + } + + PositionalAudio.prototype = Object.assign( Object.create( Audio.prototype ), { + + constructor: PositionalAudio, + + getOutput: function () { + + return this.panner; + + }, + + getRefDistance: function () { + + return this.panner.refDistance; + + }, + + setRefDistance: function ( value ) { + + this.panner.refDistance = value; + + }, + + getRolloffFactor: function () { + + return this.panner.rolloffFactor; + + }, + + setRolloffFactor: function ( value ) { + + this.panner.rolloffFactor = value; + + }, + + getDistanceModel: function () { + + return this.panner.distanceModel; + + }, + + setDistanceModel: function ( value ) { + + this.panner.distanceModel = value; + + }, + + getMaxDistance: function () { + + return this.panner.maxDistance; + + }, + + setMaxDistance: function ( value ) { + + this.panner.maxDistance = value; + + }, + + updateMatrixWorld: ( function () { + + var position = new Vector3(); + + return function updateMatrixWorld( force ) { + + Object3D.prototype.updateMatrixWorld.call( this, force ); + + position.setFromMatrixPosition( this.matrixWorld ); + + this.panner.setPosition( position.x, position.y, position.z ); + + }; + + } )() + + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function AudioAnalyser( audio, fftSize ) { + + this.analyser = audio.context.createAnalyser(); + this.analyser.fftSize = fftSize !== undefined ? fftSize : 2048; + + this.data = new Uint8Array( this.analyser.frequencyBinCount ); + + audio.getOutput().connect( this.analyser ); + + } + + Object.assign( AudioAnalyser.prototype, { + + getFrequencyData: function () { + + this.analyser.getByteFrequencyData( this.data ); + + return this.data; + + }, + + getAverageFrequency: function () { + + var value = 0, data = this.getFrequencyData(); + + for ( var i = 0; i < data.length; i ++ ) { + + value += data[ i ]; + + } + + return value / data.length; + + } + + } ); + + /** + * + * Buffered scene graph property that allows weighted accumulation. + * + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + */ + + function PropertyMixer( binding, typeName, valueSize ) { + + this.binding = binding; + this.valueSize = valueSize; + + var bufferType = Float64Array, + mixFunction; + + switch ( typeName ) { + + case 'quaternion': + mixFunction = this._slerp; + break; + + case 'string': + case 'bool': + bufferType = Array; + mixFunction = this._select; + break; + + default: + mixFunction = this._lerp; + + } + + this.buffer = new bufferType( valueSize * 4 ); + // layout: [ incoming | accu0 | accu1 | orig ] + // + // interpolators can use .buffer as their .result + // the data then goes to 'incoming' + // + // 'accu0' and 'accu1' are used frame-interleaved for + // the cumulative result and are compared to detect + // changes + // + // 'orig' stores the original state of the property + + this._mixBufferRegion = mixFunction; + + this.cumulativeWeight = 0; + + this.useCount = 0; + this.referenceCount = 0; + + } + + Object.assign( PropertyMixer.prototype, { + + // accumulate data in the 'incoming' region into 'accu' + accumulate: function ( accuIndex, weight ) { + + // note: happily accumulating nothing when weight = 0, the caller knows + // the weight and shouldn't have made the call in the first place + + var buffer = this.buffer, + stride = this.valueSize, + offset = accuIndex * stride + stride, + + currentWeight = this.cumulativeWeight; + + if ( currentWeight === 0 ) { + + // accuN := incoming * weight + + for ( var i = 0; i !== stride; ++ i ) { + + buffer[ offset + i ] = buffer[ i ]; + + } + + currentWeight = weight; + + } else { + + // accuN := accuN + incoming * weight + + currentWeight += weight; + var mix = weight / currentWeight; + this._mixBufferRegion( buffer, offset, 0, mix, stride ); + + } + + this.cumulativeWeight = currentWeight; + + }, + + // apply the state of 'accu' to the binding when accus differ + apply: function ( accuIndex ) { + + var stride = this.valueSize, + buffer = this.buffer, + offset = accuIndex * stride + stride, + + weight = this.cumulativeWeight, + + binding = this.binding; + + this.cumulativeWeight = 0; + + if ( weight < 1 ) { + + // accuN := accuN + original * ( 1 - cumulativeWeight ) + + var originalValueOffset = stride * 3; + + this._mixBufferRegion( + buffer, offset, originalValueOffset, 1 - weight, stride ); + + } + + for ( var i = stride, e = stride + stride; i !== e; ++ i ) { + + if ( buffer[ i ] !== buffer[ i + stride ] ) { + + // value has changed -> update scene graph + + binding.setValue( buffer, offset ); + break; + + } + + } + + }, + + // remember the state of the bound property and copy it to both accus + saveOriginalState: function () { + + var binding = this.binding; + + var buffer = this.buffer, + stride = this.valueSize, + + originalValueOffset = stride * 3; + + binding.getValue( buffer, originalValueOffset ); + + // accu[0..1] := orig -- initially detect changes against the original + for ( var i = stride, e = originalValueOffset; i !== e; ++ i ) { + + buffer[ i ] = buffer[ originalValueOffset + ( i % stride ) ]; + + } + + this.cumulativeWeight = 0; + + }, + + // apply the state previously taken via 'saveOriginalState' to the binding + restoreOriginalState: function () { + + var originalValueOffset = this.valueSize * 3; + this.binding.setValue( this.buffer, originalValueOffset ); + + }, + + + // mix functions + + _select: function ( buffer, dstOffset, srcOffset, t, stride ) { + + if ( t >= 0.5 ) { + + for ( var i = 0; i !== stride; ++ i ) { + + buffer[ dstOffset + i ] = buffer[ srcOffset + i ]; + + } + + } + + }, + + _slerp: function ( buffer, dstOffset, srcOffset, t ) { + + Quaternion.slerpFlat( buffer, dstOffset, buffer, dstOffset, buffer, srcOffset, t ); + + }, + + _lerp: function ( buffer, dstOffset, srcOffset, t, stride ) { + + var s = 1 - t; + + for ( var i = 0; i !== stride; ++ i ) { + + var j = dstOffset + i; + + buffer[ j ] = buffer[ j ] * s + buffer[ srcOffset + i ] * t; + + } + + } + + } ); + + /** + * + * A reference to a real property in the scene graph. + * + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + */ + + // Characters [].:/ are reserved for track binding syntax. + var RESERVED_CHARS_RE = '\\[\\]\\.:\\/'; + + function Composite( targetGroup, path, optionalParsedPath ) { + + var parsedPath = optionalParsedPath || PropertyBinding.parseTrackName( path ); + + this._targetGroup = targetGroup; + this._bindings = targetGroup.subscribe_( path, parsedPath ); + + } + + Object.assign( Composite.prototype, { + + getValue: function ( array, offset ) { + + this.bind(); // bind all binding + + var firstValidIndex = this._targetGroup.nCachedObjects_, + binding = this._bindings[ firstValidIndex ]; + + // and only call .getValue on the first + if ( binding !== undefined ) binding.getValue( array, offset ); + + }, + + setValue: function ( array, offset ) { + + var bindings = this._bindings; + + for ( var i = this._targetGroup.nCachedObjects_, + n = bindings.length; i !== n; ++ i ) { + + bindings[ i ].setValue( array, offset ); + + } + + }, + + bind: function () { + + var bindings = this._bindings; + + for ( var i = this._targetGroup.nCachedObjects_, + n = bindings.length; i !== n; ++ i ) { + + bindings[ i ].bind(); + + } + + }, + + unbind: function () { + + var bindings = this._bindings; + + for ( var i = this._targetGroup.nCachedObjects_, + n = bindings.length; i !== n; ++ i ) { + + bindings[ i ].unbind(); + + } + + } + + } ); + + + function PropertyBinding( rootNode, path, parsedPath ) { + + this.path = path; + this.parsedPath = parsedPath || PropertyBinding.parseTrackName( path ); + + this.node = PropertyBinding.findNode( rootNode, this.parsedPath.nodeName ) || rootNode; + + this.rootNode = rootNode; + + } + + Object.assign( PropertyBinding, { + + Composite: Composite, + + create: function ( root, path, parsedPath ) { + + if ( ! ( root && root.isAnimationObjectGroup ) ) { + + return new PropertyBinding( root, path, parsedPath ); + + } else { + + return new PropertyBinding.Composite( root, path, parsedPath ); + + } + + }, + + /** + * Replaces spaces with underscores and removes unsupported characters from + * node names, to ensure compatibility with parseTrackName(). + * + * @param {string} name Node name to be sanitized. + * @return {string} + */ + sanitizeNodeName: ( function () { + + var reservedRe = new RegExp( '[' + RESERVED_CHARS_RE + ']', 'g' ); + + return function sanitizeNodeName( name ) { + + return name.replace( /\s/g, '_' ).replace( reservedRe, '' ); + + }; + + }() ), + + parseTrackName: function () { + + // Attempts to allow node names from any language. ES5's `\w` regexp matches + // only latin characters, and the unicode \p{L} is not yet supported. So + // instead, we exclude reserved characters and match everything else. + var wordChar = '[^' + RESERVED_CHARS_RE + ']'; + var wordCharOrDot = '[^' + RESERVED_CHARS_RE.replace( '\\.', '' ) + ']'; + + // Parent directories, delimited by '/' or ':'. Currently unused, but must + // be matched to parse the rest of the track name. + var directoryRe = /((?:WC+[\/:])*)/.source.replace( 'WC', wordChar ); + + // Target node. May contain word characters (a-zA-Z0-9_) and '.' or '-'. + var nodeRe = /(WCOD+)?/.source.replace( 'WCOD', wordCharOrDot ); + + // Object on target node, and accessor. May not contain reserved + // characters. Accessor may contain any character except closing bracket. + var objectRe = /(?:\.(WC+)(?:\[(.+)\])?)?/.source.replace( 'WC', wordChar ); + + // Property and accessor. May not contain reserved characters. Accessor may + // contain any non-bracket characters. + var propertyRe = /\.(WC+)(?:\[(.+)\])?/.source.replace( 'WC', wordChar ); + + var trackRe = new RegExp( '' + + '^' + + directoryRe + + nodeRe + + objectRe + + propertyRe + + '$' + ); + + var supportedObjectNames = [ 'material', 'materials', 'bones' ]; + + return function parseTrackName( trackName ) { + + var matches = trackRe.exec( trackName ); + + if ( ! matches ) { + + throw new Error( 'PropertyBinding: Cannot parse trackName: ' + trackName ); + + } + + var results = { + // directoryName: matches[ 1 ], // (tschw) currently unused + nodeName: matches[ 2 ], + objectName: matches[ 3 ], + objectIndex: matches[ 4 ], + propertyName: matches[ 5 ], // required + propertyIndex: matches[ 6 ] + }; + + var lastDot = results.nodeName && results.nodeName.lastIndexOf( '.' ); + + if ( lastDot !== undefined && lastDot !== - 1 ) { + + var objectName = results.nodeName.substring( lastDot + 1 ); + + // Object names must be checked against a whitelist. Otherwise, there + // is no way to parse 'foo.bar.baz': 'baz' must be a property, but + // 'bar' could be the objectName, or part of a nodeName (which can + // include '.' characters). + if ( supportedObjectNames.indexOf( objectName ) !== - 1 ) { + + results.nodeName = results.nodeName.substring( 0, lastDot ); + results.objectName = objectName; + + } + + } + + if ( results.propertyName === null || results.propertyName.length === 0 ) { + + throw new Error( 'PropertyBinding: can not parse propertyName from trackName: ' + trackName ); + + } + + return results; + + }; + + }(), + + findNode: function ( root, nodeName ) { + + if ( ! nodeName || nodeName === "" || nodeName === "root" || nodeName === "." || nodeName === - 1 || nodeName === root.name || nodeName === root.uuid ) { + + return root; + + } + + // search into skeleton bones. + if ( root.skeleton ) { + + var bone = root.skeleton.getBoneByName( nodeName ); + + if ( bone !== undefined ) { + + return bone; + + } + + } + + // search into node subtree. + if ( root.children ) { + + var searchNodeSubtree = function ( children ) { + + for ( var i = 0; i < children.length; i ++ ) { + + var childNode = children[ i ]; + + if ( childNode.name === nodeName || childNode.uuid === nodeName ) { + + return childNode; + + } + + var result = searchNodeSubtree( childNode.children ); + + if ( result ) return result; + + } + + return null; + + }; + + var subTreeNode = searchNodeSubtree( root.children ); + + if ( subTreeNode ) { + + return subTreeNode; + + } + + } + + return null; + + } + + } ); + + Object.assign( PropertyBinding.prototype, { // prototype, continued + + // these are used to "bind" a nonexistent property + _getValue_unavailable: function () {}, + _setValue_unavailable: function () {}, + + BindingType: { + Direct: 0, + EntireArray: 1, + ArrayElement: 2, + HasFromToArray: 3 + }, + + Versioning: { + None: 0, + NeedsUpdate: 1, + MatrixWorldNeedsUpdate: 2 + }, + + GetterByBindingType: [ + + function getValue_direct( buffer, offset ) { + + buffer[ offset ] = this.node[ this.propertyName ]; + + }, + + function getValue_array( buffer, offset ) { + + var source = this.resolvedProperty; + + for ( var i = 0, n = source.length; i !== n; ++ i ) { + + buffer[ offset ++ ] = source[ i ]; + + } + + }, + + function getValue_arrayElement( buffer, offset ) { + + buffer[ offset ] = this.resolvedProperty[ this.propertyIndex ]; + + }, + + function getValue_toArray( buffer, offset ) { + + this.resolvedProperty.toArray( buffer, offset ); + + } + + ], + + SetterByBindingTypeAndVersioning: [ + + [ + // Direct + + function setValue_direct( buffer, offset ) { + + this.targetObject[ this.propertyName ] = buffer[ offset ]; + + }, + + function setValue_direct_setNeedsUpdate( buffer, offset ) { + + this.targetObject[ this.propertyName ] = buffer[ offset ]; + this.targetObject.needsUpdate = true; + + }, + + function setValue_direct_setMatrixWorldNeedsUpdate( buffer, offset ) { + + this.targetObject[ this.propertyName ] = buffer[ offset ]; + this.targetObject.matrixWorldNeedsUpdate = true; + + } + + ], [ + + // EntireArray + + function setValue_array( buffer, offset ) { + + var dest = this.resolvedProperty; + + for ( var i = 0, n = dest.length; i !== n; ++ i ) { + + dest[ i ] = buffer[ offset ++ ]; + + } + + }, + + function setValue_array_setNeedsUpdate( buffer, offset ) { + + var dest = this.resolvedProperty; + + for ( var i = 0, n = dest.length; i !== n; ++ i ) { + + dest[ i ] = buffer[ offset ++ ]; + + } + + this.targetObject.needsUpdate = true; + + }, + + function setValue_array_setMatrixWorldNeedsUpdate( buffer, offset ) { + + var dest = this.resolvedProperty; + + for ( var i = 0, n = dest.length; i !== n; ++ i ) { + + dest[ i ] = buffer[ offset ++ ]; + + } + + this.targetObject.matrixWorldNeedsUpdate = true; + + } + + ], [ + + // ArrayElement + + function setValue_arrayElement( buffer, offset ) { + + this.resolvedProperty[ this.propertyIndex ] = buffer[ offset ]; + + }, + + function setValue_arrayElement_setNeedsUpdate( buffer, offset ) { + + this.resolvedProperty[ this.propertyIndex ] = buffer[ offset ]; + this.targetObject.needsUpdate = true; + + }, + + function setValue_arrayElement_setMatrixWorldNeedsUpdate( buffer, offset ) { + + this.resolvedProperty[ this.propertyIndex ] = buffer[ offset ]; + this.targetObject.matrixWorldNeedsUpdate = true; + + } + + ], [ + + // HasToFromArray + + function setValue_fromArray( buffer, offset ) { + + this.resolvedProperty.fromArray( buffer, offset ); + + }, + + function setValue_fromArray_setNeedsUpdate( buffer, offset ) { + + this.resolvedProperty.fromArray( buffer, offset ); + this.targetObject.needsUpdate = true; + + }, + + function setValue_fromArray_setMatrixWorldNeedsUpdate( buffer, offset ) { + + this.resolvedProperty.fromArray( buffer, offset ); + this.targetObject.matrixWorldNeedsUpdate = true; + + } + + ] + + ], + + getValue: function getValue_unbound( targetArray, offset ) { + + this.bind(); + this.getValue( targetArray, offset ); + + // Note: This class uses a State pattern on a per-method basis: + // 'bind' sets 'this.getValue' / 'setValue' and shadows the + // prototype version of these methods with one that represents + // the bound state. When the property is not found, the methods + // become no-ops. + + }, + + setValue: function getValue_unbound( sourceArray, offset ) { + + this.bind(); + this.setValue( sourceArray, offset ); + + }, + + // create getter / setter pair for a property in the scene graph + bind: function () { + + var targetObject = this.node, + parsedPath = this.parsedPath, + + objectName = parsedPath.objectName, + propertyName = parsedPath.propertyName, + propertyIndex = parsedPath.propertyIndex; + + if ( ! targetObject ) { + + targetObject = PropertyBinding.findNode( this.rootNode, parsedPath.nodeName ) || this.rootNode; + + this.node = targetObject; + + } + + // set fail state so we can just 'return' on error + this.getValue = this._getValue_unavailable; + this.setValue = this._setValue_unavailable; + + // ensure there is a value node + if ( ! targetObject ) { + + console.error( 'THREE.PropertyBinding: Trying to update node for track: ' + this.path + ' but it wasn\'t found.' ); + return; + + } + + if ( objectName ) { + + var objectIndex = parsedPath.objectIndex; + + // special cases were we need to reach deeper into the hierarchy to get the face materials.... + switch ( objectName ) { + + case 'materials': + + if ( ! targetObject.material ) { + + console.error( 'THREE.PropertyBinding: Can not bind to material as node does not have a material.', this ); + return; + + } + + if ( ! targetObject.material.materials ) { + + console.error( 'THREE.PropertyBinding: Can not bind to material.materials as node.material does not have a materials array.', this ); + return; + + } + + targetObject = targetObject.material.materials; + + break; + + case 'bones': + + if ( ! targetObject.skeleton ) { + + console.error( 'THREE.PropertyBinding: Can not bind to bones as node does not have a skeleton.', this ); + return; + + } + + // potential future optimization: skip this if propertyIndex is already an integer + // and convert the integer string to a true integer. + + targetObject = targetObject.skeleton.bones; + + // support resolving morphTarget names into indices. + for ( var i = 0; i < targetObject.length; i ++ ) { + + if ( targetObject[ i ].name === objectIndex ) { + + objectIndex = i; + break; + + } + + } + + break; + + default: + + if ( targetObject[ objectName ] === undefined ) { + + console.error( 'THREE.PropertyBinding: Can not bind to objectName of node undefined.', this ); + return; + + } + + targetObject = targetObject[ objectName ]; + + } + + + if ( objectIndex !== undefined ) { + + if ( targetObject[ objectIndex ] === undefined ) { + + console.error( 'THREE.PropertyBinding: Trying to bind to objectIndex of objectName, but is undefined.', this, targetObject ); + return; + + } + + targetObject = targetObject[ objectIndex ]; + + } + + } + + // resolve property + var nodeProperty = targetObject[ propertyName ]; + + if ( nodeProperty === undefined ) { + + var nodeName = parsedPath.nodeName; + + console.error( 'THREE.PropertyBinding: Trying to update property for track: ' + nodeName + + '.' + propertyName + ' but it wasn\'t found.', targetObject ); + return; + + } + + // determine versioning scheme + var versioning = this.Versioning.None; + + if ( targetObject.needsUpdate !== undefined ) { // material + + versioning = this.Versioning.NeedsUpdate; + this.targetObject = targetObject; + + } else if ( targetObject.matrixWorldNeedsUpdate !== undefined ) { // node transform + + versioning = this.Versioning.MatrixWorldNeedsUpdate; + this.targetObject = targetObject; + + } + + // determine how the property gets bound + var bindingType = this.BindingType.Direct; + + if ( propertyIndex !== undefined ) { + + // access a sub element of the property array (only primitives are supported right now) + + if ( propertyName === "morphTargetInfluences" ) { + + // potential optimization, skip this if propertyIndex is already an integer, and convert the integer string to a true integer. + + // support resolving morphTarget names into indices. + if ( ! targetObject.geometry ) { + + console.error( 'THREE.PropertyBinding: Can not bind to morphTargetInfluences because node does not have a geometry.', this ); + return; + + } + + if ( targetObject.geometry.isBufferGeometry ) { + + if ( ! targetObject.geometry.morphAttributes ) { + + console.error( 'THREE.PropertyBinding: Can not bind to morphTargetInfluences because node does not have a geometry.morphAttributes.', this ); + return; + + } + + for ( var i = 0; i < this.node.geometry.morphAttributes.position.length; i ++ ) { + + if ( targetObject.geometry.morphAttributes.position[ i ].name === propertyIndex ) { + + propertyIndex = i; + break; + + } + + } + + + } else { + + if ( ! targetObject.geometry.morphTargets ) { + + console.error( 'THREE.PropertyBinding: Can not bind to morphTargetInfluences because node does not have a geometry.morphTargets.', this ); + return; + + } + + for ( var i = 0; i < this.node.geometry.morphTargets.length; i ++ ) { + + if ( targetObject.geometry.morphTargets[ i ].name === propertyIndex ) { + + propertyIndex = i; + break; + + } + + } + + } + + } + + bindingType = this.BindingType.ArrayElement; + + this.resolvedProperty = nodeProperty; + this.propertyIndex = propertyIndex; + + } else if ( nodeProperty.fromArray !== undefined && nodeProperty.toArray !== undefined ) { + + // must use copy for Object3D.Euler/Quaternion + + bindingType = this.BindingType.HasFromToArray; + + this.resolvedProperty = nodeProperty; + + } else if ( Array.isArray( nodeProperty ) ) { + + bindingType = this.BindingType.EntireArray; + + this.resolvedProperty = nodeProperty; + + } else { + + this.propertyName = propertyName; + + } + + // select getter / setter + this.getValue = this.GetterByBindingType[ bindingType ]; + this.setValue = this.SetterByBindingTypeAndVersioning[ bindingType ][ versioning ]; + + }, + + unbind: function () { + + this.node = null; + + // back to the prototype version of getValue / setValue + // note: avoiding to mutate the shape of 'this' via 'delete' + this.getValue = this._getValue_unbound; + this.setValue = this._setValue_unbound; + + } + + } ); + + //!\ DECLARE ALIAS AFTER assign prototype ! + Object.assign( PropertyBinding.prototype, { + + // initial state of these methods that calls 'bind' + _getValue_unbound: PropertyBinding.prototype.getValue, + _setValue_unbound: PropertyBinding.prototype.setValue, + + } ); + + /** + * + * A group of objects that receives a shared animation state. + * + * Usage: + * + * - Add objects you would otherwise pass as 'root' to the + * constructor or the .clipAction method of AnimationMixer. + * + * - Instead pass this object as 'root'. + * + * - You can also add and remove objects later when the mixer + * is running. + * + * Note: + * + * Objects of this class appear as one object to the mixer, + * so cache control of the individual objects must be done + * on the group. + * + * Limitation: + * + * - The animated properties must be compatible among the + * all objects in the group. + * + * - A single property can either be controlled through a + * target group or directly, but not both. + * + * @author tschw + */ + + function AnimationObjectGroup() { + + this.uuid = _Math.generateUUID(); + + // cached objects followed by the active ones + this._objects = Array.prototype.slice.call( arguments ); + + this.nCachedObjects_ = 0; // threshold + // note: read by PropertyBinding.Composite + + var indices = {}; + this._indicesByUUID = indices; // for bookkeeping + + for ( var i = 0, n = arguments.length; i !== n; ++ i ) { + + indices[ arguments[ i ].uuid ] = i; + + } + + this._paths = []; // inside: string + this._parsedPaths = []; // inside: { we don't care, here } + this._bindings = []; // inside: Array< PropertyBinding > + this._bindingsIndicesByPath = {}; // inside: indices in these arrays + + var scope = this; + + this.stats = { + + objects: { + get total() { + + return scope._objects.length; + + }, + get inUse() { + + return this.total - scope.nCachedObjects_; + + } + }, + get bindingsPerObject() { + + return scope._bindings.length; + + } + + }; + + } + + Object.assign( AnimationObjectGroup.prototype, { + + isAnimationObjectGroup: true, + + add: function () { + + var objects = this._objects, + nObjects = objects.length, + nCachedObjects = this.nCachedObjects_, + indicesByUUID = this._indicesByUUID, + paths = this._paths, + parsedPaths = this._parsedPaths, + bindings = this._bindings, + nBindings = bindings.length, + knownObject = undefined; + + for ( var i = 0, n = arguments.length; i !== n; ++ i ) { + + var object = arguments[ i ], + uuid = object.uuid, + index = indicesByUUID[ uuid ]; + + if ( index === undefined ) { + + // unknown object -> add it to the ACTIVE region + + index = nObjects ++; + indicesByUUID[ uuid ] = index; + objects.push( object ); + + // accounting is done, now do the same for all bindings + + for ( var j = 0, m = nBindings; j !== m; ++ j ) { + + bindings[ j ].push( new PropertyBinding( object, paths[ j ], parsedPaths[ j ] ) ); + + } + + } else if ( index < nCachedObjects ) { + + knownObject = objects[ index ]; + + // move existing object to the ACTIVE region + + var firstActiveIndex = -- nCachedObjects, + lastCachedObject = objects[ firstActiveIndex ]; + + indicesByUUID[ lastCachedObject.uuid ] = index; + objects[ index ] = lastCachedObject; + + indicesByUUID[ uuid ] = firstActiveIndex; + objects[ firstActiveIndex ] = object; + + // accounting is done, now do the same for all bindings + + for ( var j = 0, m = nBindings; j !== m; ++ j ) { + + var bindingsForPath = bindings[ j ], + lastCached = bindingsForPath[ firstActiveIndex ], + binding = bindingsForPath[ index ]; + + bindingsForPath[ index ] = lastCached; + + if ( binding === undefined ) { + + // since we do not bother to create new bindings + // for objects that are cached, the binding may + // or may not exist + + binding = new PropertyBinding( object, paths[ j ], parsedPaths[ j ] ); + + } + + bindingsForPath[ firstActiveIndex ] = binding; + + } + + } else if ( objects[ index ] !== knownObject ) { + + console.error( 'THREE.AnimationObjectGroup: Different objects with the same UUID ' + + 'detected. Clean the caches or recreate your infrastructure when reloading scenes.' ); + + } // else the object is already where we want it to be + + } // for arguments + + this.nCachedObjects_ = nCachedObjects; + + }, + + remove: function () { + + var objects = this._objects, + nCachedObjects = this.nCachedObjects_, + indicesByUUID = this._indicesByUUID, + bindings = this._bindings, + nBindings = bindings.length; + + for ( var i = 0, n = arguments.length; i !== n; ++ i ) { + + var object = arguments[ i ], + uuid = object.uuid, + index = indicesByUUID[ uuid ]; + + if ( index !== undefined && index >= nCachedObjects ) { + + // move existing object into the CACHED region + + var lastCachedIndex = nCachedObjects ++, + firstActiveObject = objects[ lastCachedIndex ]; + + indicesByUUID[ firstActiveObject.uuid ] = index; + objects[ index ] = firstActiveObject; + + indicesByUUID[ uuid ] = lastCachedIndex; + objects[ lastCachedIndex ] = object; + + // accounting is done, now do the same for all bindings + + for ( var j = 0, m = nBindings; j !== m; ++ j ) { + + var bindingsForPath = bindings[ j ], + firstActive = bindingsForPath[ lastCachedIndex ], + binding = bindingsForPath[ index ]; + + bindingsForPath[ index ] = firstActive; + bindingsForPath[ lastCachedIndex ] = binding; + + } + + } + + } // for arguments + + this.nCachedObjects_ = nCachedObjects; + + }, + + // remove & forget + uncache: function () { + + var objects = this._objects, + nObjects = objects.length, + nCachedObjects = this.nCachedObjects_, + indicesByUUID = this._indicesByUUID, + bindings = this._bindings, + nBindings = bindings.length; + + for ( var i = 0, n = arguments.length; i !== n; ++ i ) { + + var object = arguments[ i ], + uuid = object.uuid, + index = indicesByUUID[ uuid ]; + + if ( index !== undefined ) { + + delete indicesByUUID[ uuid ]; + + if ( index < nCachedObjects ) { + + // object is cached, shrink the CACHED region + + var firstActiveIndex = -- nCachedObjects, + lastCachedObject = objects[ firstActiveIndex ], + lastIndex = -- nObjects, + lastObject = objects[ lastIndex ]; + + // last cached object takes this object's place + indicesByUUID[ lastCachedObject.uuid ] = index; + objects[ index ] = lastCachedObject; + + // last object goes to the activated slot and pop + indicesByUUID[ lastObject.uuid ] = firstActiveIndex; + objects[ firstActiveIndex ] = lastObject; + objects.pop(); + + // accounting is done, now do the same for all bindings + + for ( var j = 0, m = nBindings; j !== m; ++ j ) { + + var bindingsForPath = bindings[ j ], + lastCached = bindingsForPath[ firstActiveIndex ], + last = bindingsForPath[ lastIndex ]; + + bindingsForPath[ index ] = lastCached; + bindingsForPath[ firstActiveIndex ] = last; + bindingsForPath.pop(); + + } + + } else { + + // object is active, just swap with the last and pop + + var lastIndex = -- nObjects, + lastObject = objects[ lastIndex ]; + + indicesByUUID[ lastObject.uuid ] = index; + objects[ index ] = lastObject; + objects.pop(); + + // accounting is done, now do the same for all bindings + + for ( var j = 0, m = nBindings; j !== m; ++ j ) { + + var bindingsForPath = bindings[ j ]; + + bindingsForPath[ index ] = bindingsForPath[ lastIndex ]; + bindingsForPath.pop(); + + } + + } // cached or active + + } // if object is known + + } // for arguments + + this.nCachedObjects_ = nCachedObjects; + + }, + + // Internal interface used by befriended PropertyBinding.Composite: + + subscribe_: function ( path, parsedPath ) { + + // returns an array of bindings for the given path that is changed + // according to the contained objects in the group + + var indicesByPath = this._bindingsIndicesByPath, + index = indicesByPath[ path ], + bindings = this._bindings; + + if ( index !== undefined ) return bindings[ index ]; + + var paths = this._paths, + parsedPaths = this._parsedPaths, + objects = this._objects, + nObjects = objects.length, + nCachedObjects = this.nCachedObjects_, + bindingsForPath = new Array( nObjects ); + + index = bindings.length; + + indicesByPath[ path ] = index; + + paths.push( path ); + parsedPaths.push( parsedPath ); + bindings.push( bindingsForPath ); + + for ( var i = nCachedObjects, n = objects.length; i !== n; ++ i ) { + + var object = objects[ i ]; + bindingsForPath[ i ] = new PropertyBinding( object, path, parsedPath ); + + } + + return bindingsForPath; + + }, + + unsubscribe_: function ( path ) { + + // tells the group to forget about a property path and no longer + // update the array previously obtained with 'subscribe_' + + var indicesByPath = this._bindingsIndicesByPath, + index = indicesByPath[ path ]; + + if ( index !== undefined ) { + + var paths = this._paths, + parsedPaths = this._parsedPaths, + bindings = this._bindings, + lastBindingsIndex = bindings.length - 1, + lastBindings = bindings[ lastBindingsIndex ], + lastBindingsPath = path[ lastBindingsIndex ]; + + indicesByPath[ lastBindingsPath ] = index; + + bindings[ index ] = lastBindings; + bindings.pop(); + + parsedPaths[ index ] = parsedPaths[ lastBindingsIndex ]; + parsedPaths.pop(); + + paths[ index ] = paths[ lastBindingsIndex ]; + paths.pop(); + + } + + } + + } ); + + /** + * + * Action provided by AnimationMixer for scheduling clip playback on specific + * objects. + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + * + */ + + function AnimationAction( mixer, clip, localRoot ) { + + this._mixer = mixer; + this._clip = clip; + this._localRoot = localRoot || null; + + var tracks = clip.tracks, + nTracks = tracks.length, + interpolants = new Array( nTracks ); + + var interpolantSettings = { + endingStart: ZeroCurvatureEnding, + endingEnd: ZeroCurvatureEnding + }; + + for ( var i = 0; i !== nTracks; ++ i ) { + + var interpolant = tracks[ i ].createInterpolant( null ); + interpolants[ i ] = interpolant; + interpolant.settings = interpolantSettings; + + } + + this._interpolantSettings = interpolantSettings; + + this._interpolants = interpolants; // bound by the mixer + + // inside: PropertyMixer (managed by the mixer) + this._propertyBindings = new Array( nTracks ); + + this._cacheIndex = null; // for the memory manager + this._byClipCacheIndex = null; // for the memory manager + + this._timeScaleInterpolant = null; + this._weightInterpolant = null; + + this.loop = LoopRepeat; + this._loopCount = - 1; + + // global mixer time when the action is to be started + // it's set back to 'null' upon start of the action + this._startTime = null; + + // scaled local time of the action + // gets clamped or wrapped to 0..clip.duration according to loop + this.time = 0; + + this.timeScale = 1; + this._effectiveTimeScale = 1; + + this.weight = 1; + this._effectiveWeight = 1; + + this.repetitions = Infinity; // no. of repetitions when looping + + this.paused = false; // true -> zero effective time scale + this.enabled = true; // false -> zero effective weight + + this.clampWhenFinished = false; // keep feeding the last frame? + + this.zeroSlopeAtStart = true; // for smooth interpolation w/o separate + this.zeroSlopeAtEnd = true; // clips for start, loop and end + + } + + Object.assign( AnimationAction.prototype, { + + // State & Scheduling + + play: function () { + + this._mixer._activateAction( this ); + + return this; + + }, + + stop: function () { + + this._mixer._deactivateAction( this ); + + return this.reset(); + + }, + + reset: function () { + + this.paused = false; + this.enabled = true; + + this.time = 0; // restart clip + this._loopCount = - 1; // forget previous loops + this._startTime = null; // forget scheduling + + return this.stopFading().stopWarping(); + + }, + + isRunning: function () { + + return this.enabled && ! this.paused && this.timeScale !== 0 && + this._startTime === null && this._mixer._isActiveAction( this ); + + }, + + // return true when play has been called + isScheduled: function () { + + return this._mixer._isActiveAction( this ); + + }, + + startAt: function ( time ) { + + this._startTime = time; + + return this; + + }, + + setLoop: function ( mode, repetitions ) { + + this.loop = mode; + this.repetitions = repetitions; + + return this; + + }, + + // Weight + + // set the weight stopping any scheduled fading + // although .enabled = false yields an effective weight of zero, this + // method does *not* change .enabled, because it would be confusing + setEffectiveWeight: function ( weight ) { + + this.weight = weight; + + // note: same logic as when updated at runtime + this._effectiveWeight = this.enabled ? weight : 0; + + return this.stopFading(); + + }, + + // return the weight considering fading and .enabled + getEffectiveWeight: function () { + + return this._effectiveWeight; + + }, + + fadeIn: function ( duration ) { + + return this._scheduleFading( duration, 0, 1 ); + + }, + + fadeOut: function ( duration ) { + + return this._scheduleFading( duration, 1, 0 ); + + }, + + crossFadeFrom: function ( fadeOutAction, duration, warp ) { + + fadeOutAction.fadeOut( duration ); + this.fadeIn( duration ); + + if ( warp ) { + + var fadeInDuration = this._clip.duration, + fadeOutDuration = fadeOutAction._clip.duration, + + startEndRatio = fadeOutDuration / fadeInDuration, + endStartRatio = fadeInDuration / fadeOutDuration; + + fadeOutAction.warp( 1.0, startEndRatio, duration ); + this.warp( endStartRatio, 1.0, duration ); + + } + + return this; + + }, + + crossFadeTo: function ( fadeInAction, duration, warp ) { + + return fadeInAction.crossFadeFrom( this, duration, warp ); + + }, + + stopFading: function () { + + var weightInterpolant = this._weightInterpolant; + + if ( weightInterpolant !== null ) { + + this._weightInterpolant = null; + this._mixer._takeBackControlInterpolant( weightInterpolant ); + + } + + return this; + + }, + + // Time Scale Control + + // set the time scale stopping any scheduled warping + // although .paused = true yields an effective time scale of zero, this + // method does *not* change .paused, because it would be confusing + setEffectiveTimeScale: function ( timeScale ) { + + this.timeScale = timeScale; + this._effectiveTimeScale = this.paused ? 0 : timeScale; + + return this.stopWarping(); + + }, + + // return the time scale considering warping and .paused + getEffectiveTimeScale: function () { + + return this._effectiveTimeScale; + + }, + + setDuration: function ( duration ) { + + this.timeScale = this._clip.duration / duration; + + return this.stopWarping(); + + }, + + syncWith: function ( action ) { + + this.time = action.time; + this.timeScale = action.timeScale; + + return this.stopWarping(); + + }, + + halt: function ( duration ) { + + return this.warp( this._effectiveTimeScale, 0, duration ); + + }, + + warp: function ( startTimeScale, endTimeScale, duration ) { + + var mixer = this._mixer, now = mixer.time, + interpolant = this._timeScaleInterpolant, + + timeScale = this.timeScale; + + if ( interpolant === null ) { + + interpolant = mixer._lendControlInterpolant(); + this._timeScaleInterpolant = interpolant; + + } + + var times = interpolant.parameterPositions, + values = interpolant.sampleValues; + + times[ 0 ] = now; + times[ 1 ] = now + duration; + + values[ 0 ] = startTimeScale / timeScale; + values[ 1 ] = endTimeScale / timeScale; + + return this; + + }, + + stopWarping: function () { + + var timeScaleInterpolant = this._timeScaleInterpolant; + + if ( timeScaleInterpolant !== null ) { + + this._timeScaleInterpolant = null; + this._mixer._takeBackControlInterpolant( timeScaleInterpolant ); + + } + + return this; + + }, + + // Object Accessors + + getMixer: function () { + + return this._mixer; + + }, + + getClip: function () { + + return this._clip; + + }, + + getRoot: function () { + + return this._localRoot || this._mixer._root; + + }, + + // Interna + + _update: function ( time, deltaTime, timeDirection, accuIndex ) { + + // called by the mixer + + if ( ! this.enabled ) { + + // call ._updateWeight() to update ._effectiveWeight + + this._updateWeight( time ); + return; + + } + + var startTime = this._startTime; + + if ( startTime !== null ) { + + // check for scheduled start of action + + var timeRunning = ( time - startTime ) * timeDirection; + if ( timeRunning < 0 || timeDirection === 0 ) { + + return; // yet to come / don't decide when delta = 0 + + } + + // start + + this._startTime = null; // unschedule + deltaTime = timeDirection * timeRunning; + + } + + // apply time scale and advance time + + deltaTime *= this._updateTimeScale( time ); + var clipTime = this._updateTime( deltaTime ); + + // note: _updateTime may disable the action resulting in + // an effective weight of 0 + + var weight = this._updateWeight( time ); + + if ( weight > 0 ) { + + var interpolants = this._interpolants; + var propertyMixers = this._propertyBindings; + + for ( var j = 0, m = interpolants.length; j !== m; ++ j ) { + + interpolants[ j ].evaluate( clipTime ); + propertyMixers[ j ].accumulate( accuIndex, weight ); + + } + + } + + }, + + _updateWeight: function ( time ) { + + var weight = 0; + + if ( this.enabled ) { + + weight = this.weight; + var interpolant = this._weightInterpolant; + + if ( interpolant !== null ) { + + var interpolantValue = interpolant.evaluate( time )[ 0 ]; + + weight *= interpolantValue; + + if ( time > interpolant.parameterPositions[ 1 ] ) { + + this.stopFading(); + + if ( interpolantValue === 0 ) { + + // faded out, disable + this.enabled = false; + + } + + } + + } + + } + + this._effectiveWeight = weight; + return weight; + + }, + + _updateTimeScale: function ( time ) { + + var timeScale = 0; + + if ( ! this.paused ) { + + timeScale = this.timeScale; + + var interpolant = this._timeScaleInterpolant; + + if ( interpolant !== null ) { + + var interpolantValue = interpolant.evaluate( time )[ 0 ]; + + timeScale *= interpolantValue; + + if ( time > interpolant.parameterPositions[ 1 ] ) { + + this.stopWarping(); + + if ( timeScale === 0 ) { + + // motion has halted, pause + this.paused = true; + + } else { + + // warp done - apply final time scale + this.timeScale = timeScale; + + } + + } + + } + + } + + this._effectiveTimeScale = timeScale; + return timeScale; + + }, + + _updateTime: function ( deltaTime ) { + + var time = this.time + deltaTime; + + if ( deltaTime === 0 ) return time; + + var duration = this._clip.duration, + + loop = this.loop, + loopCount = this._loopCount; + + if ( loop === LoopOnce ) { + + if ( loopCount === - 1 ) { + + // just started + + this._loopCount = 0; + this._setEndings( true, true, false ); + + } + + handle_stop: { + + if ( time >= duration ) { + + time = duration; + + } else if ( time < 0 ) { + + time = 0; + + } else break handle_stop; + + if ( this.clampWhenFinished ) this.paused = true; + else this.enabled = false; + + this._mixer.dispatchEvent( { + type: 'finished', action: this, + direction: deltaTime < 0 ? - 1 : 1 + } ); + + } + + } else { // repetitive Repeat or PingPong + + var pingPong = ( loop === LoopPingPong ); + + if ( loopCount === - 1 ) { + + // just started + + if ( deltaTime >= 0 ) { + + loopCount = 0; + + this._setEndings( true, this.repetitions === 0, pingPong ); + + } else { + + // when looping in reverse direction, the initial + // transition through zero counts as a repetition, + // so leave loopCount at -1 + + this._setEndings( this.repetitions === 0, true, pingPong ); + + } + + } + + if ( time >= duration || time < 0 ) { + + // wrap around + + var loopDelta = Math.floor( time / duration ); // signed + time -= duration * loopDelta; + + loopCount += Math.abs( loopDelta ); + + var pending = this.repetitions - loopCount; + + if ( pending <= 0 ) { + + // have to stop (switch state, clamp time, fire event) + + if ( this.clampWhenFinished ) this.paused = true; + else this.enabled = false; + + time = deltaTime > 0 ? duration : 0; + + this._mixer.dispatchEvent( { + type: 'finished', action: this, + direction: deltaTime > 0 ? 1 : - 1 + } ); + + } else { + + // keep running + + if ( pending === 1 ) { + + // entering the last round + + var atStart = deltaTime < 0; + this._setEndings( atStart, ! atStart, pingPong ); + + } else { + + this._setEndings( false, false, pingPong ); + + } + + this._loopCount = loopCount; + + this._mixer.dispatchEvent( { + type: 'loop', action: this, loopDelta: loopDelta + } ); + + } + + } + + if ( pingPong && ( loopCount & 1 ) === 1 ) { + + // invert time for the "pong round" + + this.time = time; + return duration - time; + + } + + } + + this.time = time; + return time; + + }, + + _setEndings: function ( atStart, atEnd, pingPong ) { + + var settings = this._interpolantSettings; + + if ( pingPong ) { + + settings.endingStart = ZeroSlopeEnding; + settings.endingEnd = ZeroSlopeEnding; + + } else { + + // assuming for LoopOnce atStart == atEnd == true + + if ( atStart ) { + + settings.endingStart = this.zeroSlopeAtStart ? ZeroSlopeEnding : ZeroCurvatureEnding; + + } else { + + settings.endingStart = WrapAroundEnding; + + } + + if ( atEnd ) { + + settings.endingEnd = this.zeroSlopeAtEnd ? ZeroSlopeEnding : ZeroCurvatureEnding; + + } else { + + settings.endingEnd = WrapAroundEnding; + + } + + } + + }, + + _scheduleFading: function ( duration, weightNow, weightThen ) { + + var mixer = this._mixer, now = mixer.time, + interpolant = this._weightInterpolant; + + if ( interpolant === null ) { + + interpolant = mixer._lendControlInterpolant(); + this._weightInterpolant = interpolant; + + } + + var times = interpolant.parameterPositions, + values = interpolant.sampleValues; + + times[ 0 ] = now; values[ 0 ] = weightNow; + times[ 1 ] = now + duration; values[ 1 ] = weightThen; + + return this; + + } + + } ); + + /** + * + * Player for AnimationClips. + * + * + * @author Ben Houston / http://clara.io/ + * @author David Sarno / http://lighthaus.us/ + * @author tschw + */ + + function AnimationMixer( root ) { + + this._root = root; + this._initMemoryManager(); + this._accuIndex = 0; + + this.time = 0; + + this.timeScale = 1.0; + + } + + AnimationMixer.prototype = Object.assign( Object.create( EventDispatcher.prototype ), { + + constructor: AnimationMixer, + + _bindAction: function ( action, prototypeAction ) { + + var root = action._localRoot || this._root, + tracks = action._clip.tracks, + nTracks = tracks.length, + bindings = action._propertyBindings, + interpolants = action._interpolants, + rootUuid = root.uuid, + bindingsByRoot = this._bindingsByRootAndName, + bindingsByName = bindingsByRoot[ rootUuid ]; + + if ( bindingsByName === undefined ) { + + bindingsByName = {}; + bindingsByRoot[ rootUuid ] = bindingsByName; + + } + + for ( var i = 0; i !== nTracks; ++ i ) { + + var track = tracks[ i ], + trackName = track.name, + binding = bindingsByName[ trackName ]; + + if ( binding !== undefined ) { + + bindings[ i ] = binding; + + } else { + + binding = bindings[ i ]; + + if ( binding !== undefined ) { + + // existing binding, make sure the cache knows + + if ( binding._cacheIndex === null ) { + + ++ binding.referenceCount; + this._addInactiveBinding( binding, rootUuid, trackName ); + + } + + continue; + + } + + var path = prototypeAction && prototypeAction. + _propertyBindings[ i ].binding.parsedPath; + + binding = new PropertyMixer( + PropertyBinding.create( root, trackName, path ), + track.ValueTypeName, track.getValueSize() ); + + ++ binding.referenceCount; + this._addInactiveBinding( binding, rootUuid, trackName ); + + bindings[ i ] = binding; + + } + + interpolants[ i ].resultBuffer = binding.buffer; + + } + + }, + + _activateAction: function ( action ) { + + if ( ! this._isActiveAction( action ) ) { + + if ( action._cacheIndex === null ) { + + // this action has been forgotten by the cache, but the user + // appears to be still using it -> rebind + + var rootUuid = ( action._localRoot || this._root ).uuid, + clipUuid = action._clip.uuid, + actionsForClip = this._actionsByClip[ clipUuid ]; + + this._bindAction( action, + actionsForClip && actionsForClip.knownActions[ 0 ] ); + + this._addInactiveAction( action, clipUuid, rootUuid ); + + } + + var bindings = action._propertyBindings; + + // increment reference counts / sort out state + for ( var i = 0, n = bindings.length; i !== n; ++ i ) { + + var binding = bindings[ i ]; + + if ( binding.useCount ++ === 0 ) { + + this._lendBinding( binding ); + binding.saveOriginalState(); + + } + + } + + this._lendAction( action ); + + } + + }, + + _deactivateAction: function ( action ) { + + if ( this._isActiveAction( action ) ) { + + var bindings = action._propertyBindings; + + // decrement reference counts / sort out state + for ( var i = 0, n = bindings.length; i !== n; ++ i ) { + + var binding = bindings[ i ]; + + if ( -- binding.useCount === 0 ) { + + binding.restoreOriginalState(); + this._takeBackBinding( binding ); + + } + + } + + this._takeBackAction( action ); + + } + + }, + + // Memory manager + + _initMemoryManager: function () { + + this._actions = []; // 'nActiveActions' followed by inactive ones + this._nActiveActions = 0; + + this._actionsByClip = {}; + // inside: + // { + // knownActions: Array< AnimationAction > - used as prototypes + // actionByRoot: AnimationAction - lookup + // } + + + this._bindings = []; // 'nActiveBindings' followed by inactive ones + this._nActiveBindings = 0; + + this._bindingsByRootAndName = {}; // inside: Map< name, PropertyMixer > + + + this._controlInterpolants = []; // same game as above + this._nActiveControlInterpolants = 0; + + var scope = this; + + this.stats = { + + actions: { + get total() { + + return scope._actions.length; + + }, + get inUse() { + + return scope._nActiveActions; + + } + }, + bindings: { + get total() { + + return scope._bindings.length; + + }, + get inUse() { + + return scope._nActiveBindings; + + } + }, + controlInterpolants: { + get total() { + + return scope._controlInterpolants.length; + + }, + get inUse() { + + return scope._nActiveControlInterpolants; + + } + } + + }; + + }, + + // Memory management for AnimationAction objects + + _isActiveAction: function ( action ) { + + var index = action._cacheIndex; + return index !== null && index < this._nActiveActions; + + }, + + _addInactiveAction: function ( action, clipUuid, rootUuid ) { + + var actions = this._actions, + actionsByClip = this._actionsByClip, + actionsForClip = actionsByClip[ clipUuid ]; + + if ( actionsForClip === undefined ) { + + actionsForClip = { + + knownActions: [ action ], + actionByRoot: {} + + }; + + action._byClipCacheIndex = 0; + + actionsByClip[ clipUuid ] = actionsForClip; + + } else { + + var knownActions = actionsForClip.knownActions; + + action._byClipCacheIndex = knownActions.length; + knownActions.push( action ); + + } + + action._cacheIndex = actions.length; + actions.push( action ); + + actionsForClip.actionByRoot[ rootUuid ] = action; + + }, + + _removeInactiveAction: function ( action ) { + + var actions = this._actions, + lastInactiveAction = actions[ actions.length - 1 ], + cacheIndex = action._cacheIndex; + + lastInactiveAction._cacheIndex = cacheIndex; + actions[ cacheIndex ] = lastInactiveAction; + actions.pop(); + + action._cacheIndex = null; + + + var clipUuid = action._clip.uuid, + actionsByClip = this._actionsByClip, + actionsForClip = actionsByClip[ clipUuid ], + knownActionsForClip = actionsForClip.knownActions, + + lastKnownAction = + knownActionsForClip[ knownActionsForClip.length - 1 ], + + byClipCacheIndex = action._byClipCacheIndex; + + lastKnownAction._byClipCacheIndex = byClipCacheIndex; + knownActionsForClip[ byClipCacheIndex ] = lastKnownAction; + knownActionsForClip.pop(); + + action._byClipCacheIndex = null; + + + var actionByRoot = actionsForClip.actionByRoot, + rootUuid = ( action._localRoot || this._root ).uuid; + + delete actionByRoot[ rootUuid ]; + + if ( knownActionsForClip.length === 0 ) { + + delete actionsByClip[ clipUuid ]; + + } + + this._removeInactiveBindingsForAction( action ); + + }, + + _removeInactiveBindingsForAction: function ( action ) { + + var bindings = action._propertyBindings; + for ( var i = 0, n = bindings.length; i !== n; ++ i ) { + + var binding = bindings[ i ]; + + if ( -- binding.referenceCount === 0 ) { + + this._removeInactiveBinding( binding ); + + } + + } + + }, + + _lendAction: function ( action ) { + + // [ active actions | inactive actions ] + // [ active actions >| inactive actions ] + // s a + // <-swap-> + // a s + + var actions = this._actions, + prevIndex = action._cacheIndex, + + lastActiveIndex = this._nActiveActions ++, + + firstInactiveAction = actions[ lastActiveIndex ]; + + action._cacheIndex = lastActiveIndex; + actions[ lastActiveIndex ] = action; + + firstInactiveAction._cacheIndex = prevIndex; + actions[ prevIndex ] = firstInactiveAction; + + }, + + _takeBackAction: function ( action ) { + + // [ active actions | inactive actions ] + // [ active actions |< inactive actions ] + // a s + // <-swap-> + // s a + + var actions = this._actions, + prevIndex = action._cacheIndex, + + firstInactiveIndex = -- this._nActiveActions, + + lastActiveAction = actions[ firstInactiveIndex ]; + + action._cacheIndex = firstInactiveIndex; + actions[ firstInactiveIndex ] = action; + + lastActiveAction._cacheIndex = prevIndex; + actions[ prevIndex ] = lastActiveAction; + + }, + + // Memory management for PropertyMixer objects + + _addInactiveBinding: function ( binding, rootUuid, trackName ) { + + var bindingsByRoot = this._bindingsByRootAndName, + bindingByName = bindingsByRoot[ rootUuid ], + + bindings = this._bindings; + + if ( bindingByName === undefined ) { + + bindingByName = {}; + bindingsByRoot[ rootUuid ] = bindingByName; + + } + + bindingByName[ trackName ] = binding; + + binding._cacheIndex = bindings.length; + bindings.push( binding ); + + }, + + _removeInactiveBinding: function ( binding ) { + + var bindings = this._bindings, + propBinding = binding.binding, + rootUuid = propBinding.rootNode.uuid, + trackName = propBinding.path, + bindingsByRoot = this._bindingsByRootAndName, + bindingByName = bindingsByRoot[ rootUuid ], + + lastInactiveBinding = bindings[ bindings.length - 1 ], + cacheIndex = binding._cacheIndex; + + lastInactiveBinding._cacheIndex = cacheIndex; + bindings[ cacheIndex ] = lastInactiveBinding; + bindings.pop(); + + delete bindingByName[ trackName ]; + + remove_empty_map: { + + for ( var _ in bindingByName ) break remove_empty_map; // eslint-disable-line no-unused-vars + + delete bindingsByRoot[ rootUuid ]; + + } + + }, + + _lendBinding: function ( binding ) { + + var bindings = this._bindings, + prevIndex = binding._cacheIndex, + + lastActiveIndex = this._nActiveBindings ++, + + firstInactiveBinding = bindings[ lastActiveIndex ]; + + binding._cacheIndex = lastActiveIndex; + bindings[ lastActiveIndex ] = binding; + + firstInactiveBinding._cacheIndex = prevIndex; + bindings[ prevIndex ] = firstInactiveBinding; + + }, + + _takeBackBinding: function ( binding ) { + + var bindings = this._bindings, + prevIndex = binding._cacheIndex, + + firstInactiveIndex = -- this._nActiveBindings, + + lastActiveBinding = bindings[ firstInactiveIndex ]; + + binding._cacheIndex = firstInactiveIndex; + bindings[ firstInactiveIndex ] = binding; + + lastActiveBinding._cacheIndex = prevIndex; + bindings[ prevIndex ] = lastActiveBinding; + + }, + + + // Memory management of Interpolants for weight and time scale + + _lendControlInterpolant: function () { + + var interpolants = this._controlInterpolants, + lastActiveIndex = this._nActiveControlInterpolants ++, + interpolant = interpolants[ lastActiveIndex ]; + + if ( interpolant === undefined ) { + + interpolant = new LinearInterpolant( + new Float32Array( 2 ), new Float32Array( 2 ), + 1, this._controlInterpolantsResultBuffer ); + + interpolant.__cacheIndex = lastActiveIndex; + interpolants[ lastActiveIndex ] = interpolant; + + } + + return interpolant; + + }, + + _takeBackControlInterpolant: function ( interpolant ) { + + var interpolants = this._controlInterpolants, + prevIndex = interpolant.__cacheIndex, + + firstInactiveIndex = -- this._nActiveControlInterpolants, + + lastActiveInterpolant = interpolants[ firstInactiveIndex ]; + + interpolant.__cacheIndex = firstInactiveIndex; + interpolants[ firstInactiveIndex ] = interpolant; + + lastActiveInterpolant.__cacheIndex = prevIndex; + interpolants[ prevIndex ] = lastActiveInterpolant; + + }, + + _controlInterpolantsResultBuffer: new Float32Array( 1 ), + + // return an action for a clip optionally using a custom root target + // object (this method allocates a lot of dynamic memory in case a + // previously unknown clip/root combination is specified) + clipAction: function ( clip, optionalRoot ) { + + var root = optionalRoot || this._root, + rootUuid = root.uuid, + + clipObject = typeof clip === 'string' ? + AnimationClip.findByName( root, clip ) : clip, + + clipUuid = clipObject !== null ? clipObject.uuid : clip, + + actionsForClip = this._actionsByClip[ clipUuid ], + prototypeAction = null; + + if ( actionsForClip !== undefined ) { + + var existingAction = + actionsForClip.actionByRoot[ rootUuid ]; + + if ( existingAction !== undefined ) { + + return existingAction; + + } + + // we know the clip, so we don't have to parse all + // the bindings again but can just copy + prototypeAction = actionsForClip.knownActions[ 0 ]; + + // also, take the clip from the prototype action + if ( clipObject === null ) + clipObject = prototypeAction._clip; + + } + + // clip must be known when specified via string + if ( clipObject === null ) return null; + + // allocate all resources required to run it + var newAction = new AnimationAction( this, clipObject, optionalRoot ); + + this._bindAction( newAction, prototypeAction ); + + // and make the action known to the memory manager + this._addInactiveAction( newAction, clipUuid, rootUuid ); + + return newAction; + + }, + + // get an existing action + existingAction: function ( clip, optionalRoot ) { + + var root = optionalRoot || this._root, + rootUuid = root.uuid, + + clipObject = typeof clip === 'string' ? + AnimationClip.findByName( root, clip ) : clip, + + clipUuid = clipObject ? clipObject.uuid : clip, + + actionsForClip = this._actionsByClip[ clipUuid ]; + + if ( actionsForClip !== undefined ) { + + return actionsForClip.actionByRoot[ rootUuid ] || null; + + } + + return null; + + }, + + // deactivates all previously scheduled actions + stopAllAction: function () { + + var actions = this._actions, + nActions = this._nActiveActions, + bindings = this._bindings, + nBindings = this._nActiveBindings; + + this._nActiveActions = 0; + this._nActiveBindings = 0; + + for ( var i = 0; i !== nActions; ++ i ) { + + actions[ i ].reset(); + + } + + for ( var i = 0; i !== nBindings; ++ i ) { + + bindings[ i ].useCount = 0; + + } + + return this; + + }, + + // advance the time and update apply the animation + update: function ( deltaTime ) { + + deltaTime *= this.timeScale; + + var actions = this._actions, + nActions = this._nActiveActions, + + time = this.time += deltaTime, + timeDirection = Math.sign( deltaTime ), + + accuIndex = this._accuIndex ^= 1; + + // run active actions + + for ( var i = 0; i !== nActions; ++ i ) { + + var action = actions[ i ]; + + action._update( time, deltaTime, timeDirection, accuIndex ); + + } + + // update scene graph + + var bindings = this._bindings, + nBindings = this._nActiveBindings; + + for ( var i = 0; i !== nBindings; ++ i ) { + + bindings[ i ].apply( accuIndex ); + + } + + return this; + + }, + + // return this mixer's root target object + getRoot: function () { + + return this._root; + + }, + + // free all resources specific to a particular clip + uncacheClip: function ( clip ) { + + var actions = this._actions, + clipUuid = clip.uuid, + actionsByClip = this._actionsByClip, + actionsForClip = actionsByClip[ clipUuid ]; + + if ( actionsForClip !== undefined ) { + + // note: just calling _removeInactiveAction would mess up the + // iteration state and also require updating the state we can + // just throw away + + var actionsToRemove = actionsForClip.knownActions; + + for ( var i = 0, n = actionsToRemove.length; i !== n; ++ i ) { + + var action = actionsToRemove[ i ]; + + this._deactivateAction( action ); + + var cacheIndex = action._cacheIndex, + lastInactiveAction = actions[ actions.length - 1 ]; + + action._cacheIndex = null; + action._byClipCacheIndex = null; + + lastInactiveAction._cacheIndex = cacheIndex; + actions[ cacheIndex ] = lastInactiveAction; + actions.pop(); + + this._removeInactiveBindingsForAction( action ); + + } + + delete actionsByClip[ clipUuid ]; + + } + + }, + + // free all resources specific to a particular root target object + uncacheRoot: function ( root ) { + + var rootUuid = root.uuid, + actionsByClip = this._actionsByClip; + + for ( var clipUuid in actionsByClip ) { + + var actionByRoot = actionsByClip[ clipUuid ].actionByRoot, + action = actionByRoot[ rootUuid ]; + + if ( action !== undefined ) { + + this._deactivateAction( action ); + this._removeInactiveAction( action ); + + } + + } + + var bindingsByRoot = this._bindingsByRootAndName, + bindingByName = bindingsByRoot[ rootUuid ]; + + if ( bindingByName !== undefined ) { + + for ( var trackName in bindingByName ) { + + var binding = bindingByName[ trackName ]; + binding.restoreOriginalState(); + this._removeInactiveBinding( binding ); + + } + + } + + }, + + // remove a targeted clip from the cache + uncacheAction: function ( clip, optionalRoot ) { + + var action = this.existingAction( clip, optionalRoot ); + + if ( action !== null ) { + + this._deactivateAction( action ); + this._removeInactiveAction( action ); + + } + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function Uniform( value ) { + + if ( typeof value === 'string' ) { + + console.warn( 'THREE.Uniform: Type parameter is no longer needed.' ); + value = arguments[ 1 ]; + + } + + this.value = value; + + } + + Uniform.prototype.clone = function () { + + return new Uniform( this.value.clone === undefined ? this.value : this.value.clone() ); + + }; + + /** + * @author benaadams / https://twitter.com/ben_a_adams + */ + + function InstancedBufferGeometry() { + + BufferGeometry.call( this ); + + this.type = 'InstancedBufferGeometry'; + this.maxInstancedCount = undefined; + + } + + InstancedBufferGeometry.prototype = Object.assign( Object.create( BufferGeometry.prototype ), { + + constructor: InstancedBufferGeometry, + + isInstancedBufferGeometry: true, + + copy: function ( source ) { + + BufferGeometry.prototype.copy.call( this, source ); + + this.maxInstancedCount = source.maxInstancedCount; + + return this; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + } + + } ); + + /** + * @author benaadams / https://twitter.com/ben_a_adams + */ + + function InterleavedBufferAttribute( interleavedBuffer, itemSize, offset, normalized ) { + + this.data = interleavedBuffer; + this.itemSize = itemSize; + this.offset = offset; + + this.normalized = normalized === true; + + } + + Object.defineProperties( InterleavedBufferAttribute.prototype, { + + count: { + + get: function () { + + return this.data.count; + + } + + }, + + array: { + + get: function () { + + return this.data.array; + + } + + } + + } ); + + Object.assign( InterleavedBufferAttribute.prototype, { + + isInterleavedBufferAttribute: true, + + setX: function ( index, x ) { + + this.data.array[ index * this.data.stride + this.offset ] = x; + + return this; + + }, + + setY: function ( index, y ) { + + this.data.array[ index * this.data.stride + this.offset + 1 ] = y; + + return this; + + }, + + setZ: function ( index, z ) { + + this.data.array[ index * this.data.stride + this.offset + 2 ] = z; + + return this; + + }, + + setW: function ( index, w ) { + + this.data.array[ index * this.data.stride + this.offset + 3 ] = w; + + return this; + + }, + + getX: function ( index ) { + + return this.data.array[ index * this.data.stride + this.offset ]; + + }, + + getY: function ( index ) { + + return this.data.array[ index * this.data.stride + this.offset + 1 ]; + + }, + + getZ: function ( index ) { + + return this.data.array[ index * this.data.stride + this.offset + 2 ]; + + }, + + getW: function ( index ) { + + return this.data.array[ index * this.data.stride + this.offset + 3 ]; + + }, + + setXY: function ( index, x, y ) { + + index = index * this.data.stride + this.offset; + + this.data.array[ index + 0 ] = x; + this.data.array[ index + 1 ] = y; + + return this; + + }, + + setXYZ: function ( index, x, y, z ) { + + index = index * this.data.stride + this.offset; + + this.data.array[ index + 0 ] = x; + this.data.array[ index + 1 ] = y; + this.data.array[ index + 2 ] = z; + + return this; + + }, + + setXYZW: function ( index, x, y, z, w ) { + + index = index * this.data.stride + this.offset; + + this.data.array[ index + 0 ] = x; + this.data.array[ index + 1 ] = y; + this.data.array[ index + 2 ] = z; + this.data.array[ index + 3 ] = w; + + return this; + + } + + } ); + + /** + * @author benaadams / https://twitter.com/ben_a_adams + */ + + function InterleavedBuffer( array, stride ) { + + this.array = array; + this.stride = stride; + this.count = array !== undefined ? array.length / stride : 0; + + this.dynamic = false; + this.updateRange = { offset: 0, count: - 1 }; + + this.version = 0; + + } + + Object.defineProperty( InterleavedBuffer.prototype, 'needsUpdate', { + + set: function ( value ) { + + if ( value === true ) this.version ++; + + } + + } ); + + Object.assign( InterleavedBuffer.prototype, { + + isInterleavedBuffer: true, + + onUploadCallback: function () {}, + + setArray: function ( array ) { + + if ( Array.isArray( array ) ) { + + throw new TypeError( 'THREE.BufferAttribute: array should be a Typed Array.' ); + + } + + this.count = array !== undefined ? array.length / this.stride : 0; + this.array = array; + + return this; + + }, + + setDynamic: function ( value ) { + + this.dynamic = value; + + return this; + + }, + + copy: function ( source ) { + + this.array = new source.array.constructor( source.array ); + this.count = source.count; + this.stride = source.stride; + this.dynamic = source.dynamic; + + return this; + + }, + + copyAt: function ( index1, attribute, index2 ) { + + index1 *= this.stride; + index2 *= attribute.stride; + + for ( var i = 0, l = this.stride; i < l; i ++ ) { + + this.array[ index1 + i ] = attribute.array[ index2 + i ]; + + } + + return this; + + }, + + set: function ( value, offset ) { + + if ( offset === undefined ) offset = 0; + + this.array.set( value, offset ); + + return this; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + onUpload: function ( callback ) { + + this.onUploadCallback = callback; + + return this; + + } + + } ); + + /** + * @author benaadams / https://twitter.com/ben_a_adams + */ + + function InstancedInterleavedBuffer( array, stride, meshPerAttribute ) { + + InterleavedBuffer.call( this, array, stride ); + + this.meshPerAttribute = meshPerAttribute || 1; + + } + + InstancedInterleavedBuffer.prototype = Object.assign( Object.create( InterleavedBuffer.prototype ), { + + constructor: InstancedInterleavedBuffer, + + isInstancedInterleavedBuffer: true, + + copy: function ( source ) { + + InterleavedBuffer.prototype.copy.call( this, source ); + + this.meshPerAttribute = source.meshPerAttribute; + + return this; + + } + + } ); + + /** + * @author benaadams / https://twitter.com/ben_a_adams + */ + + function InstancedBufferAttribute( array, itemSize, meshPerAttribute ) { + + BufferAttribute.call( this, array, itemSize ); + + this.meshPerAttribute = meshPerAttribute || 1; + + } + + InstancedBufferAttribute.prototype = Object.assign( Object.create( BufferAttribute.prototype ), { + + constructor: InstancedBufferAttribute, + + isInstancedBufferAttribute: true, + + copy: function ( source ) { + + BufferAttribute.prototype.copy.call( this, source ); + + this.meshPerAttribute = source.meshPerAttribute; + + return this; + + } + + } ); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author bhouston / http://clara.io/ + * @author stephomi / http://stephaneginier.com/ + */ + + function Raycaster( origin, direction, near, far ) { + + this.ray = new Ray( origin, direction ); + // direction is assumed to be normalized (for accurate distance calculations) + + this.near = near || 0; + this.far = far || Infinity; + + this.params = { + Mesh: {}, + Line: {}, + LOD: {}, + Points: { threshold: 1 }, + Sprite: {} + }; + + Object.defineProperties( this.params, { + PointCloud: { + get: function () { + + console.warn( 'THREE.Raycaster: params.PointCloud has been renamed to params.Points.' ); + return this.Points; + + } + } + } ); + + } + + function ascSort( a, b ) { + + return a.distance - b.distance; + + } + + function intersectObject( object, raycaster, intersects, recursive ) { + + if ( object.visible === false ) return; + + object.raycast( raycaster, intersects ); + + if ( recursive === true ) { + + var children = object.children; + + for ( var i = 0, l = children.length; i < l; i ++ ) { + + intersectObject( children[ i ], raycaster, intersects, true ); + + } + + } + + } + + Object.assign( Raycaster.prototype, { + + linePrecision: 1, + + set: function ( origin, direction ) { + + // direction is assumed to be normalized (for accurate distance calculations) + + this.ray.set( origin, direction ); + + }, + + setFromCamera: function ( coords, camera ) { + + if ( ( camera && camera.isPerspectiveCamera ) ) { + + this.ray.origin.setFromMatrixPosition( camera.matrixWorld ); + this.ray.direction.set( coords.x, coords.y, 0.5 ).unproject( camera ).sub( this.ray.origin ).normalize(); + + } else if ( ( camera && camera.isOrthographicCamera ) ) { + + this.ray.origin.set( coords.x, coords.y, ( camera.near + camera.far ) / ( camera.near - camera.far ) ).unproject( camera ); // set origin in plane of camera + this.ray.direction.set( 0, 0, - 1 ).transformDirection( camera.matrixWorld ); + + } else { + + console.error( 'THREE.Raycaster: Unsupported camera type.' ); + + } + + }, + + intersectObject: function ( object, recursive, optionalTarget ) { + + var intersects = optionalTarget || []; + + intersectObject( object, this, intersects, recursive ); + + intersects.sort( ascSort ); + + return intersects; + + }, + + intersectObjects: function ( objects, recursive, optionalTarget ) { + + var intersects = optionalTarget || []; + + if ( Array.isArray( objects ) === false ) { + + console.warn( 'THREE.Raycaster.intersectObjects: objects is not an Array.' ); + return intersects; + + } + + for ( var i = 0, l = objects.length; i < l; i ++ ) { + + intersectObject( objects[ i ], this, intersects, recursive ); + + } + + intersects.sort( ascSort ); + + return intersects; + + } + + } ); + + /** + * @author alteredq / http://alteredqualia.com/ + */ + + function Clock( autoStart ) { + + this.autoStart = ( autoStart !== undefined ) ? autoStart : true; + + this.startTime = 0; + this.oldTime = 0; + this.elapsedTime = 0; + + this.running = false; + + } + + Object.assign( Clock.prototype, { + + start: function () { + + this.startTime = ( typeof performance === 'undefined' ? Date : performance ).now(); // see #10732 + + this.oldTime = this.startTime; + this.elapsedTime = 0; + this.running = true; + + }, + + stop: function () { + + this.getElapsedTime(); + this.running = false; + this.autoStart = false; + + }, + + getElapsedTime: function () { + + this.getDelta(); + return this.elapsedTime; + + }, + + getDelta: function () { + + var diff = 0; + + if ( this.autoStart && ! this.running ) { + + this.start(); + return 0; + + } + + if ( this.running ) { + + var newTime = ( typeof performance === 'undefined' ? Date : performance ).now(); + + diff = ( newTime - this.oldTime ) / 1000; + this.oldTime = newTime; + + this.elapsedTime += diff; + + } + + return diff; + + } + + } ); + + /** + * @author bhouston / http://clara.io + * @author WestLangley / http://github.com/WestLangley + * + * Ref: https://en.wikipedia.org/wiki/Spherical_coordinate_system + * + * The poles (phi) are at the positive and negative y axis. + * The equator starts at positive z. + */ + + function Spherical( radius, phi, theta ) { + + this.radius = ( radius !== undefined ) ? radius : 1.0; + this.phi = ( phi !== undefined ) ? phi : 0; // up / down towards top and bottom pole + this.theta = ( theta !== undefined ) ? theta : 0; // around the equator of the sphere + + return this; + + } + + Object.assign( Spherical.prototype, { + + set: function ( radius, phi, theta ) { + + this.radius = radius; + this.phi = phi; + this.theta = theta; + + return this; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( other ) { + + this.radius = other.radius; + this.phi = other.phi; + this.theta = other.theta; + + return this; + + }, + + // restrict phi to be betwee EPS and PI-EPS + makeSafe: function () { + + var EPS = 0.000001; + this.phi = Math.max( EPS, Math.min( Math.PI - EPS, this.phi ) ); + + return this; + + }, + + setFromVector3: function ( vec3 ) { + + this.radius = vec3.length(); + + if ( this.radius === 0 ) { + + this.theta = 0; + this.phi = 0; + + } else { + + this.theta = Math.atan2( vec3.x, vec3.z ); // equator angle around y-up axis + this.phi = Math.acos( _Math.clamp( vec3.y / this.radius, - 1, 1 ) ); // polar angle + + } + + return this; + + } + + } ); + + /** + * @author Mugen87 / https://github.com/Mugen87 + * + * Ref: https://en.wikipedia.org/wiki/Cylindrical_coordinate_system + * + */ + + function Cylindrical( radius, theta, y ) { + + this.radius = ( radius !== undefined ) ? radius : 1.0; // distance from the origin to a point in the x-z plane + this.theta = ( theta !== undefined ) ? theta : 0; // counterclockwise angle in the x-z plane measured in radians from the positive z-axis + this.y = ( y !== undefined ) ? y : 0; // height above the x-z plane + + return this; + + } + + Object.assign( Cylindrical.prototype, { + + set: function ( radius, theta, y ) { + + this.radius = radius; + this.theta = theta; + this.y = y; + + return this; + + }, + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( other ) { + + this.radius = other.radius; + this.theta = other.theta; + this.y = other.y; + + return this; + + }, + + setFromVector3: function ( vec3 ) { + + this.radius = Math.sqrt( vec3.x * vec3.x + vec3.z * vec3.z ); + this.theta = Math.atan2( vec3.x, vec3.z ); + this.y = vec3.y; + + return this; + + } + + } ); + + /** + * @author bhouston / http://clara.io + */ + + function Box2( min, max ) { + + this.min = ( min !== undefined ) ? min : new Vector2( + Infinity, + Infinity ); + this.max = ( max !== undefined ) ? max : new Vector2( - Infinity, - Infinity ); + + } + + Object.assign( Box2.prototype, { + + set: function ( min, max ) { + + this.min.copy( min ); + this.max.copy( max ); + + return this; + + }, + + setFromPoints: function ( points ) { + + this.makeEmpty(); + + for ( var i = 0, il = points.length; i < il; i ++ ) { + + this.expandByPoint( points[ i ] ); + + } + + return this; + + }, + + setFromCenterAndSize: function () { + + var v1 = new Vector2(); + + return function setFromCenterAndSize( center, size ) { + + var halfSize = v1.copy( size ).multiplyScalar( 0.5 ); + this.min.copy( center ).sub( halfSize ); + this.max.copy( center ).add( halfSize ); + + return this; + + }; + + }(), + + clone: function () { + + return new this.constructor().copy( this ); + + }, + + copy: function ( box ) { + + this.min.copy( box.min ); + this.max.copy( box.max ); + + return this; + + }, + + makeEmpty: function () { + + this.min.x = this.min.y = + Infinity; + this.max.x = this.max.y = - Infinity; + + return this; + + }, + + isEmpty: function () { + + // this is a more robust check for empty than ( volume <= 0 ) because volume can get positive with two negative axes + + return ( this.max.x < this.min.x ) || ( this.max.y < this.min.y ); + + }, + + getCenter: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Box2: .getCenter() target is now required' ); + target = new Vector2(); + + } + + return this.isEmpty() ? target.set( 0, 0 ) : target.addVectors( this.min, this.max ).multiplyScalar( 0.5 ); + + }, + + getSize: function ( target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Box2: .getSize() target is now required' ); + target = new Vector2(); + + } + + return this.isEmpty() ? target.set( 0, 0 ) : target.subVectors( this.max, this.min ); + + }, + + expandByPoint: function ( point ) { + + this.min.min( point ); + this.max.max( point ); + + return this; + + }, + + expandByVector: function ( vector ) { + + this.min.sub( vector ); + this.max.add( vector ); + + return this; + + }, + + expandByScalar: function ( scalar ) { + + this.min.addScalar( - scalar ); + this.max.addScalar( scalar ); + + return this; + + }, + + containsPoint: function ( point ) { + + return point.x < this.min.x || point.x > this.max.x || + point.y < this.min.y || point.y > this.max.y ? false : true; + + }, + + containsBox: function ( box ) { + + return this.min.x <= box.min.x && box.max.x <= this.max.x && + this.min.y <= box.min.y && box.max.y <= this.max.y; + + }, + + getParameter: function ( point, target ) { + + // This can potentially have a divide by zero if the box + // has a size dimension of 0. + + if ( target === undefined ) { + + console.warn( 'THREE.Box2: .getParameter() target is now required' ); + target = new Vector2(); + + } + + return target.set( + ( point.x - this.min.x ) / ( this.max.x - this.min.x ), + ( point.y - this.min.y ) / ( this.max.y - this.min.y ) + ); + + }, + + intersectsBox: function ( box ) { + + // using 4 splitting planes to rule out intersections + + return box.max.x < this.min.x || box.min.x > this.max.x || + box.max.y < this.min.y || box.min.y > this.max.y ? false : true; + + }, + + clampPoint: function ( point, target ) { + + if ( target === undefined ) { + + console.warn( 'THREE.Box2: .clampPoint() target is now required' ); + target = new Vector2(); + + } + + return target.copy( point ).clamp( this.min, this.max ); + + }, + + distanceToPoint: function () { + + var v1 = new Vector2(); + + return function distanceToPoint( point ) { + + var clampedPoint = v1.copy( point ).clamp( this.min, this.max ); + return clampedPoint.sub( point ).length(); + + }; + + }(), + + intersect: function ( box ) { + + this.min.max( box.min ); + this.max.min( box.max ); + + return this; + + }, + + union: function ( box ) { + + this.min.min( box.min ); + this.max.max( box.max ); + + return this; + + }, + + translate: function ( offset ) { + + this.min.add( offset ); + this.max.add( offset ); + + return this; + + }, + + equals: function ( box ) { + + return box.min.equals( this.min ) && box.max.equals( this.max ); + + } + + } ); + + /** + * @author alteredq / http://alteredqualia.com/ + */ + + function ImmediateRenderObject( material ) { + + Object3D.call( this ); + + this.material = material; + this.render = function ( /* renderCallback */ ) {}; + + } + + ImmediateRenderObject.prototype = Object.create( Object3D.prototype ); + ImmediateRenderObject.prototype.constructor = ImmediateRenderObject; + + ImmediateRenderObject.prototype.isImmediateRenderObject = true; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author WestLangley / http://github.com/WestLangley + */ + + function VertexNormalsHelper( object, size, hex, linewidth ) { + + this.object = object; + + this.size = ( size !== undefined ) ? size : 1; + + var color = ( hex !== undefined ) ? hex : 0xff0000; + + var width = ( linewidth !== undefined ) ? linewidth : 1; + + // + + var nNormals = 0; + + var objGeometry = this.object.geometry; + + if ( objGeometry && objGeometry.isGeometry ) { + + nNormals = objGeometry.faces.length * 3; + + } else if ( objGeometry && objGeometry.isBufferGeometry ) { + + nNormals = objGeometry.attributes.normal.count; + + } + + // + + var geometry = new BufferGeometry(); + + var positions = new Float32BufferAttribute( nNormals * 2 * 3, 3 ); + + geometry.addAttribute( 'position', positions ); + + LineSegments.call( this, geometry, new LineBasicMaterial( { color: color, linewidth: width } ) ); + + // + + this.matrixAutoUpdate = false; + + this.update(); + + } + + VertexNormalsHelper.prototype = Object.create( LineSegments.prototype ); + VertexNormalsHelper.prototype.constructor = VertexNormalsHelper; + + VertexNormalsHelper.prototype.update = ( function () { + + var v1 = new Vector3(); + var v2 = new Vector3(); + var normalMatrix = new Matrix3(); + + return function update() { + + var keys = [ 'a', 'b', 'c' ]; + + this.object.updateMatrixWorld( true ); + + normalMatrix.getNormalMatrix( this.object.matrixWorld ); + + var matrixWorld = this.object.matrixWorld; + + var position = this.geometry.attributes.position; + + // + + var objGeometry = this.object.geometry; + + if ( objGeometry && objGeometry.isGeometry ) { + + var vertices = objGeometry.vertices; + + var faces = objGeometry.faces; + + var idx = 0; + + for ( var i = 0, l = faces.length; i < l; i ++ ) { + + var face = faces[ i ]; + + for ( var j = 0, jl = face.vertexNormals.length; j < jl; j ++ ) { + + var vertex = vertices[ face[ keys[ j ] ] ]; + + var normal = face.vertexNormals[ j ]; + + v1.copy( vertex ).applyMatrix4( matrixWorld ); + + v2.copy( normal ).applyMatrix3( normalMatrix ).normalize().multiplyScalar( this.size ).add( v1 ); + + position.setXYZ( idx, v1.x, v1.y, v1.z ); + + idx = idx + 1; + + position.setXYZ( idx, v2.x, v2.y, v2.z ); + + idx = idx + 1; + + } + + } + + } else if ( objGeometry && objGeometry.isBufferGeometry ) { + + var objPos = objGeometry.attributes.position; + + var objNorm = objGeometry.attributes.normal; + + var idx = 0; + + // for simplicity, ignore index and drawcalls, and render every normal + + for ( var j = 0, jl = objPos.count; j < jl; j ++ ) { + + v1.set( objPos.getX( j ), objPos.getY( j ), objPos.getZ( j ) ).applyMatrix4( matrixWorld ); + + v2.set( objNorm.getX( j ), objNorm.getY( j ), objNorm.getZ( j ) ); + + v2.applyMatrix3( normalMatrix ).normalize().multiplyScalar( this.size ).add( v1 ); + + position.setXYZ( idx, v1.x, v1.y, v1.z ); + + idx = idx + 1; + + position.setXYZ( idx, v2.x, v2.y, v2.z ); + + idx = idx + 1; + + } + + } + + position.needsUpdate = true; + + }; + + }() ); + + /** + * @author alteredq / http://alteredqualia.com/ + * @author mrdoob / http://mrdoob.com/ + * @author WestLangley / http://github.com/WestLangley + */ + + function SpotLightHelper( light, color ) { + + Object3D.call( this ); + + this.light = light; + this.light.updateMatrixWorld(); + + this.matrix = light.matrixWorld; + this.matrixAutoUpdate = false; + + this.color = color; + + var geometry = new BufferGeometry(); + + var positions = [ + 0, 0, 0, 0, 0, 1, + 0, 0, 0, 1, 0, 1, + 0, 0, 0, - 1, 0, 1, + 0, 0, 0, 0, 1, 1, + 0, 0, 0, 0, - 1, 1 + ]; + + for ( var i = 0, j = 1, l = 32; i < l; i ++, j ++ ) { + + var p1 = ( i / l ) * Math.PI * 2; + var p2 = ( j / l ) * Math.PI * 2; + + positions.push( + Math.cos( p1 ), Math.sin( p1 ), 1, + Math.cos( p2 ), Math.sin( p2 ), 1 + ); + + } + + geometry.addAttribute( 'position', new Float32BufferAttribute( positions, 3 ) ); + + var material = new LineBasicMaterial( { fog: false } ); + + this.cone = new LineSegments( geometry, material ); + this.add( this.cone ); + + this.update(); + + } + + SpotLightHelper.prototype = Object.create( Object3D.prototype ); + SpotLightHelper.prototype.constructor = SpotLightHelper; + + SpotLightHelper.prototype.dispose = function () { + + this.cone.geometry.dispose(); + this.cone.material.dispose(); + + }; + + SpotLightHelper.prototype.update = function () { + + var vector = new Vector3(); + var vector2 = new Vector3(); + + return function update() { + + this.light.updateMatrixWorld(); + + var coneLength = this.light.distance ? this.light.distance : 1000; + var coneWidth = coneLength * Math.tan( this.light.angle ); + + this.cone.scale.set( coneWidth, coneWidth, coneLength ); + + vector.setFromMatrixPosition( this.light.matrixWorld ); + vector2.setFromMatrixPosition( this.light.target.matrixWorld ); + + this.cone.lookAt( vector2.sub( vector ) ); + + if ( this.color !== undefined ) { + + this.cone.material.color.set( this.color ); + + } else { + + this.cone.material.color.copy( this.light.color ); + + } + + }; + + }(); + + /** + * @author Sean Griffin / http://twitter.com/sgrif + * @author Michael Guerrero / http://realitymeltdown.com + * @author mrdoob / http://mrdoob.com/ + * @author ikerr / http://verold.com + * @author Mugen87 / https://github.com/Mugen87 + */ + + function getBoneList( object ) { + + var boneList = []; + + if ( object && object.isBone ) { + + boneList.push( object ); + + } + + for ( var i = 0; i < object.children.length; i ++ ) { + + boneList.push.apply( boneList, getBoneList( object.children[ i ] ) ); + + } + + return boneList; + + } + + function SkeletonHelper( object ) { + + var bones = getBoneList( object ); + + var geometry = new BufferGeometry(); + + var vertices = []; + var colors = []; + + var color1 = new Color( 0, 0, 1 ); + var color2 = new Color( 0, 1, 0 ); + + for ( var i = 0; i < bones.length; i ++ ) { + + var bone = bones[ i ]; + + if ( bone.parent && bone.parent.isBone ) { + + vertices.push( 0, 0, 0 ); + vertices.push( 0, 0, 0 ); + colors.push( color1.r, color1.g, color1.b ); + colors.push( color2.r, color2.g, color2.b ); + + } + + } + + geometry.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + geometry.addAttribute( 'color', new Float32BufferAttribute( colors, 3 ) ); + + var material = new LineBasicMaterial( { vertexColors: VertexColors, depthTest: false, depthWrite: false, transparent: true } ); + + LineSegments.call( this, geometry, material ); + + this.root = object; + this.bones = bones; + + this.matrix = object.matrixWorld; + this.matrixAutoUpdate = false; + + } + + SkeletonHelper.prototype = Object.create( LineSegments.prototype ); + SkeletonHelper.prototype.constructor = SkeletonHelper; + + SkeletonHelper.prototype.updateMatrixWorld = function () { + + var vector = new Vector3(); + + var boneMatrix = new Matrix4(); + var matrixWorldInv = new Matrix4(); + + return function updateMatrixWorld( force ) { + + var bones = this.bones; + + var geometry = this.geometry; + var position = geometry.getAttribute( 'position' ); + + matrixWorldInv.getInverse( this.root.matrixWorld ); + + for ( var i = 0, j = 0; i < bones.length; i ++ ) { + + var bone = bones[ i ]; + + if ( bone.parent && bone.parent.isBone ) { + + boneMatrix.multiplyMatrices( matrixWorldInv, bone.matrixWorld ); + vector.setFromMatrixPosition( boneMatrix ); + position.setXYZ( j, vector.x, vector.y, vector.z ); + + boneMatrix.multiplyMatrices( matrixWorldInv, bone.parent.matrixWorld ); + vector.setFromMatrixPosition( boneMatrix ); + position.setXYZ( j + 1, vector.x, vector.y, vector.z ); + + j += 2; + + } + + } + + geometry.getAttribute( 'position' ).needsUpdate = true; + + Object3D.prototype.updateMatrixWorld.call( this, force ); + + }; + + }(); + + /** + * @author alteredq / http://alteredqualia.com/ + * @author mrdoob / http://mrdoob.com/ + */ + + function PointLightHelper( light, sphereSize, color ) { + + this.light = light; + this.light.updateMatrixWorld(); + + this.color = color; + + var geometry = new SphereBufferGeometry( sphereSize, 4, 2 ); + var material = new MeshBasicMaterial( { wireframe: true, fog: false } ); + + Mesh.call( this, geometry, material ); + + this.matrix = this.light.matrixWorld; + this.matrixAutoUpdate = false; + + this.update(); + + + /* + var distanceGeometry = new THREE.IcosahedronGeometry( 1, 2 ); + var distanceMaterial = new THREE.MeshBasicMaterial( { color: hexColor, fog: false, wireframe: true, opacity: 0.1, transparent: true } ); + + this.lightSphere = new THREE.Mesh( bulbGeometry, bulbMaterial ); + this.lightDistance = new THREE.Mesh( distanceGeometry, distanceMaterial ); + + var d = light.distance; + + if ( d === 0.0 ) { + + this.lightDistance.visible = false; + + } else { + + this.lightDistance.scale.set( d, d, d ); + + } + + this.add( this.lightDistance ); + */ + + } + + PointLightHelper.prototype = Object.create( Mesh.prototype ); + PointLightHelper.prototype.constructor = PointLightHelper; + + PointLightHelper.prototype.dispose = function () { + + this.geometry.dispose(); + this.material.dispose(); + + }; + + PointLightHelper.prototype.update = function () { + + if ( this.color !== undefined ) { + + this.material.color.set( this.color ); + + } else { + + this.material.color.copy( this.light.color ); + + } + + /* + var d = this.light.distance; + + if ( d === 0.0 ) { + + this.lightDistance.visible = false; + + } else { + + this.lightDistance.visible = true; + this.lightDistance.scale.set( d, d, d ); + + } + */ + + }; + + /** + * @author abelnation / http://github.com/abelnation + * @author Mugen87 / http://github.com/Mugen87 + * @author WestLangley / http://github.com/WestLangley + */ + + function RectAreaLightHelper( light, color ) { + + Object3D.call( this ); + + this.light = light; + this.light.updateMatrixWorld(); + + this.matrix = light.matrixWorld; + this.matrixAutoUpdate = false; + + this.color = color; + + var material = new LineBasicMaterial( { fog: false } ); + + var geometry = new BufferGeometry(); + + geometry.addAttribute( 'position', new BufferAttribute( new Float32Array( 5 * 3 ), 3 ) ); + + this.line = new Line( geometry, material ); + this.add( this.line ); + + + this.update(); + + } + + RectAreaLightHelper.prototype = Object.create( Object3D.prototype ); + RectAreaLightHelper.prototype.constructor = RectAreaLightHelper; + + RectAreaLightHelper.prototype.dispose = function () { + + this.children[ 0 ].geometry.dispose(); + this.children[ 0 ].material.dispose(); + + }; + + RectAreaLightHelper.prototype.update = function () { + + // calculate new dimensions of the helper + + var hx = this.light.width * 0.5; + var hy = this.light.height * 0.5; + + var position = this.line.geometry.attributes.position; + var array = position.array; + + // update vertices + + array[ 0 ] = hx; array[ 1 ] = - hy; array[ 2 ] = 0; + array[ 3 ] = hx; array[ 4 ] = hy; array[ 5 ] = 0; + array[ 6 ] = - hx; array[ 7 ] = hy; array[ 8 ] = 0; + array[ 9 ] = - hx; array[ 10 ] = - hy; array[ 11 ] = 0; + array[ 12 ] = hx; array[ 13 ] = - hy; array[ 14 ] = 0; + + position.needsUpdate = true; + + if ( this.color !== undefined ) { + + this.line.material.color.set( this.color ); + + } else { + + this.line.material.color.copy( this.light.color ); + + } + + }; + + /** + * @author alteredq / http://alteredqualia.com/ + * @author mrdoob / http://mrdoob.com/ + * @author Mugen87 / https://github.com/Mugen87 + */ + + function HemisphereLightHelper( light, size, color ) { + + Object3D.call( this ); + + this.light = light; + this.light.updateMatrixWorld(); + + this.matrix = light.matrixWorld; + this.matrixAutoUpdate = false; + + this.color = color; + + var geometry = new OctahedronBufferGeometry( size ); + geometry.rotateY( Math.PI * 0.5 ); + + this.material = new MeshBasicMaterial( { wireframe: true, fog: false } ); + if ( this.color === undefined ) this.material.vertexColors = VertexColors; + + var position = geometry.getAttribute( 'position' ); + var colors = new Float32Array( position.count * 3 ); + + geometry.addAttribute( 'color', new BufferAttribute( colors, 3 ) ); + + this.add( new Mesh( geometry, this.material ) ); + + this.update(); + + } + + HemisphereLightHelper.prototype = Object.create( Object3D.prototype ); + HemisphereLightHelper.prototype.constructor = HemisphereLightHelper; + + HemisphereLightHelper.prototype.dispose = function () { + + this.children[ 0 ].geometry.dispose(); + this.children[ 0 ].material.dispose(); + + }; + + HemisphereLightHelper.prototype.update = function () { + + var vector = new Vector3(); + + var color1 = new Color(); + var color2 = new Color(); + + return function update() { + + var mesh = this.children[ 0 ]; + + if ( this.color !== undefined ) { + + this.material.color.set( this.color ); + + } else { + + var colors = mesh.geometry.getAttribute( 'color' ); + + color1.copy( this.light.color ); + color2.copy( this.light.groundColor ); + + for ( var i = 0, l = colors.count; i < l; i ++ ) { + + var color = ( i < ( l / 2 ) ) ? color1 : color2; + + colors.setXYZ( i, color.r, color.g, color.b ); + + } + + colors.needsUpdate = true; + + } + + mesh.lookAt( vector.setFromMatrixPosition( this.light.matrixWorld ).negate() ); + + }; + + }(); + + /** + * @author mrdoob / http://mrdoob.com/ + */ + + function GridHelper( size, divisions, color1, color2 ) { + + size = size || 10; + divisions = divisions || 10; + color1 = new Color( color1 !== undefined ? color1 : 0x444444 ); + color2 = new Color( color2 !== undefined ? color2 : 0x888888 ); + + var center = divisions / 2; + var step = size / divisions; + var halfSize = size / 2; + + var vertices = [], colors = []; + + for ( var i = 0, j = 0, k = - halfSize; i <= divisions; i ++, k += step ) { + + vertices.push( - halfSize, 0, k, halfSize, 0, k ); + vertices.push( k, 0, - halfSize, k, 0, halfSize ); + + var color = i === center ? color1 : color2; + + color.toArray( colors, j ); j += 3; + color.toArray( colors, j ); j += 3; + color.toArray( colors, j ); j += 3; + color.toArray( colors, j ); j += 3; + + } + + var geometry = new BufferGeometry(); + geometry.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + geometry.addAttribute( 'color', new Float32BufferAttribute( colors, 3 ) ); + + var material = new LineBasicMaterial( { vertexColors: VertexColors } ); + + LineSegments.call( this, geometry, material ); + + } + + GridHelper.prototype = Object.create( LineSegments.prototype ); + GridHelper.prototype.constructor = GridHelper; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author Mugen87 / http://github.com/Mugen87 + * @author Hectate / http://www.github.com/Hectate + */ + + function PolarGridHelper( radius, radials, circles, divisions, color1, color2 ) { + + radius = radius || 10; + radials = radials || 16; + circles = circles || 8; + divisions = divisions || 64; + color1 = new Color( color1 !== undefined ? color1 : 0x444444 ); + color2 = new Color( color2 !== undefined ? color2 : 0x888888 ); + + var vertices = []; + var colors = []; + + var x, z; + var v, i, j, r, color; + + // create the radials + + for ( i = 0; i <= radials; i ++ ) { + + v = ( i / radials ) * ( Math.PI * 2 ); + + x = Math.sin( v ) * radius; + z = Math.cos( v ) * radius; + + vertices.push( 0, 0, 0 ); + vertices.push( x, 0, z ); + + color = ( i & 1 ) ? color1 : color2; + + colors.push( color.r, color.g, color.b ); + colors.push( color.r, color.g, color.b ); + + } + + // create the circles + + for ( i = 0; i <= circles; i ++ ) { + + color = ( i & 1 ) ? color1 : color2; + + r = radius - ( radius / circles * i ); + + for ( j = 0; j < divisions; j ++ ) { + + // first vertex + + v = ( j / divisions ) * ( Math.PI * 2 ); + + x = Math.sin( v ) * r; + z = Math.cos( v ) * r; + + vertices.push( x, 0, z ); + colors.push( color.r, color.g, color.b ); + + // second vertex + + v = ( ( j + 1 ) / divisions ) * ( Math.PI * 2 ); + + x = Math.sin( v ) * r; + z = Math.cos( v ) * r; + + vertices.push( x, 0, z ); + colors.push( color.r, color.g, color.b ); + + } + + } + + var geometry = new BufferGeometry(); + geometry.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + geometry.addAttribute( 'color', new Float32BufferAttribute( colors, 3 ) ); + + var material = new LineBasicMaterial( { vertexColors: VertexColors } ); + + LineSegments.call( this, geometry, material ); + + } + + PolarGridHelper.prototype = Object.create( LineSegments.prototype ); + PolarGridHelper.prototype.constructor = PolarGridHelper; + + /** + * @author mrdoob / http://mrdoob.com/ + * @author WestLangley / http://github.com/WestLangley + */ + + function FaceNormalsHelper( object, size, hex, linewidth ) { + + // FaceNormalsHelper only supports THREE.Geometry + + this.object = object; + + this.size = ( size !== undefined ) ? size : 1; + + var color = ( hex !== undefined ) ? hex : 0xffff00; + + var width = ( linewidth !== undefined ) ? linewidth : 1; + + // + + var nNormals = 0; + + var objGeometry = this.object.geometry; + + if ( objGeometry && objGeometry.isGeometry ) { + + nNormals = objGeometry.faces.length; + + } else { + + console.warn( 'THREE.FaceNormalsHelper: only THREE.Geometry is supported. Use THREE.VertexNormalsHelper, instead.' ); + + } + + // + + var geometry = new BufferGeometry(); + + var positions = new Float32BufferAttribute( nNormals * 2 * 3, 3 ); + + geometry.addAttribute( 'position', positions ); + + LineSegments.call( this, geometry, new LineBasicMaterial( { color: color, linewidth: width } ) ); + + // + + this.matrixAutoUpdate = false; + this.update(); + + } + + FaceNormalsHelper.prototype = Object.create( LineSegments.prototype ); + FaceNormalsHelper.prototype.constructor = FaceNormalsHelper; + + FaceNormalsHelper.prototype.update = ( function () { + + var v1 = new Vector3(); + var v2 = new Vector3(); + var normalMatrix = new Matrix3(); + + return function update() { + + this.object.updateMatrixWorld( true ); + + normalMatrix.getNormalMatrix( this.object.matrixWorld ); + + var matrixWorld = this.object.matrixWorld; + + var position = this.geometry.attributes.position; + + // + + var objGeometry = this.object.geometry; + + var vertices = objGeometry.vertices; + + var faces = objGeometry.faces; + + var idx = 0; + + for ( var i = 0, l = faces.length; i < l; i ++ ) { + + var face = faces[ i ]; + + var normal = face.normal; + + v1.copy( vertices[ face.a ] ) + .add( vertices[ face.b ] ) + .add( vertices[ face.c ] ) + .divideScalar( 3 ) + .applyMatrix4( matrixWorld ); + + v2.copy( normal ).applyMatrix3( normalMatrix ).normalize().multiplyScalar( this.size ).add( v1 ); + + position.setXYZ( idx, v1.x, v1.y, v1.z ); + + idx = idx + 1; + + position.setXYZ( idx, v2.x, v2.y, v2.z ); + + idx = idx + 1; + + } + + position.needsUpdate = true; + + }; + + }() ); + + /** + * @author alteredq / http://alteredqualia.com/ + * @author mrdoob / http://mrdoob.com/ + * @author WestLangley / http://github.com/WestLangley + */ + + function DirectionalLightHelper( light, size, color ) { + + Object3D.call( this ); + + this.light = light; + this.light.updateMatrixWorld(); + + this.matrix = light.matrixWorld; + this.matrixAutoUpdate = false; + + this.color = color; + + if ( size === undefined ) size = 1; + + var geometry = new BufferGeometry(); + geometry.addAttribute( 'position', new Float32BufferAttribute( [ + - size, size, 0, + size, size, 0, + size, - size, 0, + - size, - size, 0, + - size, size, 0 + ], 3 ) ); + + var material = new LineBasicMaterial( { fog: false } ); + + this.lightPlane = new Line( geometry, material ); + this.add( this.lightPlane ); + + geometry = new BufferGeometry(); + geometry.addAttribute( 'position', new Float32BufferAttribute( [ 0, 0, 0, 0, 0, 1 ], 3 ) ); + + this.targetLine = new Line( geometry, material ); + this.add( this.targetLine ); + + this.update(); + + } + + DirectionalLightHelper.prototype = Object.create( Object3D.prototype ); + DirectionalLightHelper.prototype.constructor = DirectionalLightHelper; + + DirectionalLightHelper.prototype.dispose = function () { + + this.lightPlane.geometry.dispose(); + this.lightPlane.material.dispose(); + this.targetLine.geometry.dispose(); + this.targetLine.material.dispose(); + + }; + + DirectionalLightHelper.prototype.update = function () { + + var v1 = new Vector3(); + var v2 = new Vector3(); + var v3 = new Vector3(); + + return function update() { + + v1.setFromMatrixPosition( this.light.matrixWorld ); + v2.setFromMatrixPosition( this.light.target.matrixWorld ); + v3.subVectors( v2, v1 ); + + this.lightPlane.lookAt( v3 ); + + if ( this.color !== undefined ) { + + this.lightPlane.material.color.set( this.color ); + this.targetLine.material.color.set( this.color ); + + } else { + + this.lightPlane.material.color.copy( this.light.color ); + this.targetLine.material.color.copy( this.light.color ); + + } + + this.targetLine.lookAt( v3 ); + this.targetLine.scale.z = v3.length(); + + }; + + }(); + + /** + * @author alteredq / http://alteredqualia.com/ + * @author Mugen87 / https://github.com/Mugen87 + * + * - shows frustum, line of sight and up of the camera + * - suitable for fast updates + * - based on frustum visualization in lightgl.js shadowmap example + * http://evanw.github.com/lightgl.js/tests/shadowmap.html + */ + + function CameraHelper( camera ) { + + var geometry = new BufferGeometry(); + var material = new LineBasicMaterial( { color: 0xffffff, vertexColors: FaceColors } ); + + var vertices = []; + var colors = []; + + var pointMap = {}; + + // colors + + var colorFrustum = new Color( 0xffaa00 ); + var colorCone = new Color( 0xff0000 ); + var colorUp = new Color( 0x00aaff ); + var colorTarget = new Color( 0xffffff ); + var colorCross = new Color( 0x333333 ); + + // near + + addLine( 'n1', 'n2', colorFrustum ); + addLine( 'n2', 'n4', colorFrustum ); + addLine( 'n4', 'n3', colorFrustum ); + addLine( 'n3', 'n1', colorFrustum ); + + // far + + addLine( 'f1', 'f2', colorFrustum ); + addLine( 'f2', 'f4', colorFrustum ); + addLine( 'f4', 'f3', colorFrustum ); + addLine( 'f3', 'f1', colorFrustum ); + + // sides + + addLine( 'n1', 'f1', colorFrustum ); + addLine( 'n2', 'f2', colorFrustum ); + addLine( 'n3', 'f3', colorFrustum ); + addLine( 'n4', 'f4', colorFrustum ); + + // cone + + addLine( 'p', 'n1', colorCone ); + addLine( 'p', 'n2', colorCone ); + addLine( 'p', 'n3', colorCone ); + addLine( 'p', 'n4', colorCone ); + + // up + + addLine( 'u1', 'u2', colorUp ); + addLine( 'u2', 'u3', colorUp ); + addLine( 'u3', 'u1', colorUp ); + + // target + + addLine( 'c', 't', colorTarget ); + addLine( 'p', 'c', colorCross ); + + // cross + + addLine( 'cn1', 'cn2', colorCross ); + addLine( 'cn3', 'cn4', colorCross ); + + addLine( 'cf1', 'cf2', colorCross ); + addLine( 'cf3', 'cf4', colorCross ); + + function addLine( a, b, color ) { + + addPoint( a, color ); + addPoint( b, color ); + + } + + function addPoint( id, color ) { + + vertices.push( 0, 0, 0 ); + colors.push( color.r, color.g, color.b ); + + if ( pointMap[ id ] === undefined ) { + + pointMap[ id ] = []; + + } + + pointMap[ id ].push( ( vertices.length / 3 ) - 1 ); + + } + + geometry.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + geometry.addAttribute( 'color', new Float32BufferAttribute( colors, 3 ) ); + + LineSegments.call( this, geometry, material ); + + this.camera = camera; + if ( this.camera.updateProjectionMatrix ) this.camera.updateProjectionMatrix(); + + this.matrix = camera.matrixWorld; + this.matrixAutoUpdate = false; + + this.pointMap = pointMap; + + this.update(); + + } + + CameraHelper.prototype = Object.create( LineSegments.prototype ); + CameraHelper.prototype.constructor = CameraHelper; + + CameraHelper.prototype.update = function () { + + var geometry, pointMap; + + var vector = new Vector3(); + var camera = new Camera(); + + function setPoint( point, x, y, z ) { + + vector.set( x, y, z ).unproject( camera ); + + var points = pointMap[ point ]; + + if ( points !== undefined ) { + + var position = geometry.getAttribute( 'position' ); + + for ( var i = 0, l = points.length; i < l; i ++ ) { + + position.setXYZ( points[ i ], vector.x, vector.y, vector.z ); + + } + + } + + } + + return function update() { + + geometry = this.geometry; + pointMap = this.pointMap; + + var w = 1, h = 1; + + // we need just camera projection matrix + // world matrix must be identity + + camera.projectionMatrix.copy( this.camera.projectionMatrix ); + + // center / target + + setPoint( 'c', 0, 0, - 1 ); + setPoint( 't', 0, 0, 1 ); + + // near + + setPoint( 'n1', - w, - h, - 1 ); + setPoint( 'n2', w, - h, - 1 ); + setPoint( 'n3', - w, h, - 1 ); + setPoint( 'n4', w, h, - 1 ); + + // far + + setPoint( 'f1', - w, - h, 1 ); + setPoint( 'f2', w, - h, 1 ); + setPoint( 'f3', - w, h, 1 ); + setPoint( 'f4', w, h, 1 ); + + // up + + setPoint( 'u1', w * 0.7, h * 1.1, - 1 ); + setPoint( 'u2', - w * 0.7, h * 1.1, - 1 ); + setPoint( 'u3', 0, h * 2, - 1 ); + + // cross + + setPoint( 'cf1', - w, 0, 1 ); + setPoint( 'cf2', w, 0, 1 ); + setPoint( 'cf3', 0, - h, 1 ); + setPoint( 'cf4', 0, h, 1 ); + + setPoint( 'cn1', - w, 0, - 1 ); + setPoint( 'cn2', w, 0, - 1 ); + setPoint( 'cn3', 0, - h, - 1 ); + setPoint( 'cn4', 0, h, - 1 ); + + geometry.getAttribute( 'position' ).needsUpdate = true; + + }; + + }(); + + /** + * @author mrdoob / http://mrdoob.com/ + * @author Mugen87 / http://github.com/Mugen87 + */ + + function BoxHelper( object, color ) { + + this.object = object; + + if ( color === undefined ) color = 0xffff00; + + var indices = new Uint16Array( [ 0, 1, 1, 2, 2, 3, 3, 0, 4, 5, 5, 6, 6, 7, 7, 4, 0, 4, 1, 5, 2, 6, 3, 7 ] ); + var positions = new Float32Array( 8 * 3 ); + + var geometry = new BufferGeometry(); + geometry.setIndex( new BufferAttribute( indices, 1 ) ); + geometry.addAttribute( 'position', new BufferAttribute( positions, 3 ) ); + + LineSegments.call( this, geometry, new LineBasicMaterial( { color: color } ) ); + + this.matrixAutoUpdate = false; + + this.update(); + + } + + BoxHelper.prototype = Object.create( LineSegments.prototype ); + BoxHelper.prototype.constructor = BoxHelper; + + BoxHelper.prototype.update = ( function () { + + var box = new Box3(); + + return function update( object ) { + + if ( object !== undefined ) { + + console.warn( 'THREE.BoxHelper: .update() has no longer arguments.' ); + + } + + if ( this.object !== undefined ) { + + box.setFromObject( this.object ); + + } + + if ( box.isEmpty() ) return; + + var min = box.min; + var max = box.max; + + /* + 5____4 + 1/___0/| + | 6__|_7 + 2/___3/ + + 0: max.x, max.y, max.z + 1: min.x, max.y, max.z + 2: min.x, min.y, max.z + 3: max.x, min.y, max.z + 4: max.x, max.y, min.z + 5: min.x, max.y, min.z + 6: min.x, min.y, min.z + 7: max.x, min.y, min.z + */ + + var position = this.geometry.attributes.position; + var array = position.array; + + array[ 0 ] = max.x; array[ 1 ] = max.y; array[ 2 ] = max.z; + array[ 3 ] = min.x; array[ 4 ] = max.y; array[ 5 ] = max.z; + array[ 6 ] = min.x; array[ 7 ] = min.y; array[ 8 ] = max.z; + array[ 9 ] = max.x; array[ 10 ] = min.y; array[ 11 ] = max.z; + array[ 12 ] = max.x; array[ 13 ] = max.y; array[ 14 ] = min.z; + array[ 15 ] = min.x; array[ 16 ] = max.y; array[ 17 ] = min.z; + array[ 18 ] = min.x; array[ 19 ] = min.y; array[ 20 ] = min.z; + array[ 21 ] = max.x; array[ 22 ] = min.y; array[ 23 ] = min.z; + + position.needsUpdate = true; + + this.geometry.computeBoundingSphere(); + + }; + + } )(); + + BoxHelper.prototype.setFromObject = function ( object ) { + + this.object = object; + this.update(); + + return this; + + }; + + /** + * @author WestLangley / http://github.com/WestLangley + */ + + function Box3Helper( box, hex ) { + + this.type = 'Box3Helper'; + + this.box = box; + + var color = ( hex !== undefined ) ? hex : 0xffff00; + + var indices = new Uint16Array( [ 0, 1, 1, 2, 2, 3, 3, 0, 4, 5, 5, 6, 6, 7, 7, 4, 0, 4, 1, 5, 2, 6, 3, 7 ] ); + + var positions = [ 1, 1, 1, - 1, 1, 1, - 1, - 1, 1, 1, - 1, 1, 1, 1, - 1, - 1, 1, - 1, - 1, - 1, - 1, 1, - 1, - 1 ]; + + var geometry = new BufferGeometry(); + + geometry.setIndex( new BufferAttribute( indices, 1 ) ); + + geometry.addAttribute( 'position', new Float32BufferAttribute( positions, 3 ) ); + + LineSegments.call( this, geometry, new LineBasicMaterial( { color: color } ) ); + + this.geometry.computeBoundingSphere(); + + } + + Box3Helper.prototype = Object.create( LineSegments.prototype ); + Box3Helper.prototype.constructor = Box3Helper; + + Box3Helper.prototype.updateMatrixWorld = function ( force ) { + + var box = this.box; + + if ( box.isEmpty() ) return; + + box.getCenter( this.position ); + + box.getSize( this.scale ); + + this.scale.multiplyScalar( 0.5 ); + + Object3D.prototype.updateMatrixWorld.call( this, force ); + + }; + + /** + * @author WestLangley / http://github.com/WestLangley + */ + + function PlaneHelper( plane, size, hex ) { + + this.type = 'PlaneHelper'; + + this.plane = plane; + + this.size = ( size === undefined ) ? 1 : size; + + var color = ( hex !== undefined ) ? hex : 0xffff00; + + var positions = [ 1, - 1, 1, - 1, 1, 1, - 1, - 1, 1, 1, 1, 1, - 1, 1, 1, - 1, - 1, 1, 1, - 1, 1, 1, 1, 1, 0, 0, 1, 0, 0, 0 ]; + + var geometry = new BufferGeometry(); + geometry.addAttribute( 'position', new Float32BufferAttribute( positions, 3 ) ); + geometry.computeBoundingSphere(); + + Line.call( this, geometry, new LineBasicMaterial( { color: color } ) ); + + // + + var positions2 = [ 1, 1, 1, - 1, 1, 1, - 1, - 1, 1, 1, 1, 1, - 1, - 1, 1, 1, - 1, 1 ]; + + var geometry2 = new BufferGeometry(); + geometry2.addAttribute( 'position', new Float32BufferAttribute( positions2, 3 ) ); + geometry2.computeBoundingSphere(); + + this.add( new Mesh( geometry2, new MeshBasicMaterial( { color: color, opacity: 0.2, transparent: true, depthWrite: false } ) ) ); + + } + + PlaneHelper.prototype = Object.create( Line.prototype ); + PlaneHelper.prototype.constructor = PlaneHelper; + + PlaneHelper.prototype.updateMatrixWorld = function ( force ) { + + var scale = - this.plane.constant; + + if ( Math.abs( scale ) < 1e-8 ) scale = 1e-8; // sign does not matter + + this.scale.set( 0.5 * this.size, 0.5 * this.size, scale ); + + this.children[ 0 ].material.side = ( scale < 0 ) ? BackSide : FrontSide; // renderer flips side when determinant < 0; flipping not wanted here + + this.lookAt( this.plane.normal ); + + Object3D.prototype.updateMatrixWorld.call( this, force ); + + }; + + /** + * @author WestLangley / http://github.com/WestLangley + * @author zz85 / http://github.com/zz85 + * @author bhouston / http://clara.io + * + * Creates an arrow for visualizing directions + * + * Parameters: + * dir - Vector3 + * origin - Vector3 + * length - Number + * color - color in hex value + * headLength - Number + * headWidth - Number + */ + + var lineGeometry, coneGeometry; + + function ArrowHelper( dir, origin, length, color, headLength, headWidth ) { + + // dir is assumed to be normalized + + Object3D.call( this ); + + if ( color === undefined ) color = 0xffff00; + if ( length === undefined ) length = 1; + if ( headLength === undefined ) headLength = 0.2 * length; + if ( headWidth === undefined ) headWidth = 0.2 * headLength; + + if ( lineGeometry === undefined ) { + + lineGeometry = new BufferGeometry(); + lineGeometry.addAttribute( 'position', new Float32BufferAttribute( [ 0, 0, 0, 0, 1, 0 ], 3 ) ); + + coneGeometry = new CylinderBufferGeometry( 0, 0.5, 1, 5, 1 ); + coneGeometry.translate( 0, - 0.5, 0 ); + + } + + this.position.copy( origin ); + + this.line = new Line( lineGeometry, new LineBasicMaterial( { color: color } ) ); + this.line.matrixAutoUpdate = false; + this.add( this.line ); + + this.cone = new Mesh( coneGeometry, new MeshBasicMaterial( { color: color } ) ); + this.cone.matrixAutoUpdate = false; + this.add( this.cone ); + + this.setDirection( dir ); + this.setLength( length, headLength, headWidth ); + + } + + ArrowHelper.prototype = Object.create( Object3D.prototype ); + ArrowHelper.prototype.constructor = ArrowHelper; + + ArrowHelper.prototype.setDirection = ( function () { + + var axis = new Vector3(); + var radians; + + return function setDirection( dir ) { + + // dir is assumed to be normalized + + if ( dir.y > 0.99999 ) { + + this.quaternion.set( 0, 0, 0, 1 ); + + } else if ( dir.y < - 0.99999 ) { + + this.quaternion.set( 1, 0, 0, 0 ); + + } else { + + axis.set( dir.z, 0, - dir.x ).normalize(); + + radians = Math.acos( dir.y ); + + this.quaternion.setFromAxisAngle( axis, radians ); + + } + + }; + + }() ); + + ArrowHelper.prototype.setLength = function ( length, headLength, headWidth ) { + + if ( headLength === undefined ) headLength = 0.2 * length; + if ( headWidth === undefined ) headWidth = 0.2 * headLength; + + this.line.scale.set( 1, Math.max( 0, length - headLength ), 1 ); + this.line.updateMatrix(); + + this.cone.scale.set( headWidth, headLength, headWidth ); + this.cone.position.y = length; + this.cone.updateMatrix(); + + }; + + ArrowHelper.prototype.setColor = function ( color ) { + + this.line.material.color.copy( color ); + this.cone.material.color.copy( color ); + + }; + + /** + * @author sroucheray / http://sroucheray.org/ + * @author mrdoob / http://mrdoob.com/ + */ + + function AxesHelper( size ) { + + size = size || 1; + + var vertices = [ + 0, 0, 0, size, 0, 0, + 0, 0, 0, 0, size, 0, + 0, 0, 0, 0, 0, size + ]; + + var colors = [ + 1, 0, 0, 1, 0.6, 0, + 0, 1, 0, 0.6, 1, 0, + 0, 0, 1, 0, 0.6, 1 + ]; + + var geometry = new BufferGeometry(); + geometry.addAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) ); + geometry.addAttribute( 'color', new Float32BufferAttribute( colors, 3 ) ); + + var material = new LineBasicMaterial( { vertexColors: VertexColors } ); + + LineSegments.call( this, geometry, material ); + + } + + AxesHelper.prototype = Object.create( LineSegments.prototype ); + AxesHelper.prototype.constructor = AxesHelper; + + // + + Curve.create = function ( construct, getPoint ) { + + console.log( 'THREE.Curve.create() has been deprecated' ); + + construct.prototype = Object.create( Curve.prototype ); + construct.prototype.constructor = construct; + construct.prototype.getPoint = getPoint; + + return construct; + + }; + + // + + Object.assign( CurvePath.prototype, { + + createPointsGeometry: function ( divisions ) { + + console.warn( 'THREE.CurvePath: .createPointsGeometry() has been removed. Use new THREE.Geometry().setFromPoints( points ) instead.' ); + + // generate geometry from path points (for Line or Points objects) + + var pts = this.getPoints( divisions ); + return this.createGeometry( pts ); + + }, + + createSpacedPointsGeometry: function ( divisions ) { + + console.warn( 'THREE.CurvePath: .createSpacedPointsGeometry() has been removed. Use new THREE.Geometry().setFromPoints( points ) instead.' ); + + // generate geometry from equidistant sampling along the path + + var pts = this.getSpacedPoints( divisions ); + return this.createGeometry( pts ); + + }, + + createGeometry: function ( points ) { + + console.warn( 'THREE.CurvePath: .createGeometry() has been removed. Use new THREE.Geometry().setFromPoints( points ) instead.' ); + + var geometry = new Geometry(); + + for ( var i = 0, l = points.length; i < l; i ++ ) { + + var point = points[ i ]; + geometry.vertices.push( new Vector3( point.x, point.y, point.z || 0 ) ); + + } + + return geometry; + + } + + } ); + + // + + Object.assign( Path.prototype, { + + fromPoints: function ( points ) { + + console.warn( 'THREE.Path: .fromPoints() has been renamed to .setFromPoints().' ); + this.setFromPoints( points ); + + } + + } ); + + // + + function Spline( points ) { + + console.warn( 'THREE.Spline has been removed. Use THREE.CatmullRomCurve3 instead.' ); + + CatmullRomCurve3.call( this, points ); + this.type = 'catmullrom'; + + } + + Spline.prototype = Object.create( CatmullRomCurve3.prototype ); + + Object.assign( Spline.prototype, { + + initFromArray: function ( /* a */ ) { + + console.error( 'THREE.Spline: .initFromArray() has been removed.' ); + + }, + getControlPointsArray: function ( /* optionalTarget */ ) { + + console.error( 'THREE.Spline: .getControlPointsArray() has been removed.' ); + + }, + reparametrizeByArcLength: function ( /* samplingCoef */ ) { + + console.error( 'THREE.Spline: .reparametrizeByArcLength() has been removed.' ); + + } + + } ); + + GridHelper.prototype.setColors = function () { + + console.error( 'THREE.GridHelper: setColors() has been deprecated, pass them in the constructor instead.' ); + + }; + + SkeletonHelper.prototype.update = function () { + + console.error( 'THREE.SkeletonHelper: update() no longer needs to be called.' ); + + }; + + // + + Object.assign( Loader.prototype, { + + extractUrlBase: function ( url ) { + + console.warn( 'THREE.Loader: .extractUrlBase() has been deprecated. Use THREE.LoaderUtils.extractUrlBase() instead.' ); + return LoaderUtils.extractUrlBase( url ); + + } + + } ); + + // + + Object.assign( Box2.prototype, { + + center: function ( optionalTarget ) { + + console.warn( 'THREE.Box2: .center() has been renamed to .getCenter().' ); + return this.getCenter( optionalTarget ); + + }, + empty: function () { + + console.warn( 'THREE.Box2: .empty() has been renamed to .isEmpty().' ); + return this.isEmpty(); + + }, + isIntersectionBox: function ( box ) { + + console.warn( 'THREE.Box2: .isIntersectionBox() has been renamed to .intersectsBox().' ); + return this.intersectsBox( box ); + + }, + size: function ( optionalTarget ) { + + console.warn( 'THREE.Box2: .size() has been renamed to .getSize().' ); + return this.getSize( optionalTarget ); + + } + } ); + + Object.assign( Box3.prototype, { + + center: function ( optionalTarget ) { + + console.warn( 'THREE.Box3: .center() has been renamed to .getCenter().' ); + return this.getCenter( optionalTarget ); + + }, + empty: function () { + + console.warn( 'THREE.Box3: .empty() has been renamed to .isEmpty().' ); + return this.isEmpty(); + + }, + isIntersectionBox: function ( box ) { + + console.warn( 'THREE.Box3: .isIntersectionBox() has been renamed to .intersectsBox().' ); + return this.intersectsBox( box ); + + }, + isIntersectionSphere: function ( sphere ) { + + console.warn( 'THREE.Box3: .isIntersectionSphere() has been renamed to .intersectsSphere().' ); + return this.intersectsSphere( sphere ); + + }, + size: function ( optionalTarget ) { + + console.warn( 'THREE.Box3: .size() has been renamed to .getSize().' ); + return this.getSize( optionalTarget ); + + } + } ); + + Line3.prototype.center = function ( optionalTarget ) { + + console.warn( 'THREE.Line3: .center() has been renamed to .getCenter().' ); + return this.getCenter( optionalTarget ); + + }; + + Object.assign( _Math, { + + random16: function () { + + console.warn( 'THREE.Math: .random16() has been deprecated. Use Math.random() instead.' ); + return Math.random(); + + }, + + nearestPowerOfTwo: function ( value ) { + + console.warn( 'THREE.Math: .nearestPowerOfTwo() has been renamed to .floorPowerOfTwo().' ); + return _Math.floorPowerOfTwo( value ); + + }, + + nextPowerOfTwo: function ( value ) { + + console.warn( 'THREE.Math: .nextPowerOfTwo() has been renamed to .ceilPowerOfTwo().' ); + return _Math.ceilPowerOfTwo( value ); + + } + + } ); + + Object.assign( Matrix3.prototype, { + + flattenToArrayOffset: function ( array, offset ) { + + console.warn( "THREE.Matrix3: .flattenToArrayOffset() has been deprecated. Use .toArray() instead." ); + return this.toArray( array, offset ); + + }, + multiplyVector3: function ( vector ) { + + console.warn( 'THREE.Matrix3: .multiplyVector3() has been removed. Use vector.applyMatrix3( matrix ) instead.' ); + return vector.applyMatrix3( this ); + + }, + multiplyVector3Array: function ( /* a */ ) { + + console.error( 'THREE.Matrix3: .multiplyVector3Array() has been removed.' ); + + }, + applyToBuffer: function ( buffer /*, offset, length */ ) { + + console.warn( 'THREE.Matrix3: .applyToBuffer() has been removed. Use matrix.applyToBufferAttribute( attribute ) instead.' ); + return this.applyToBufferAttribute( buffer ); + + }, + applyToVector3Array: function ( /* array, offset, length */ ) { + + console.error( 'THREE.Matrix3: .applyToVector3Array() has been removed.' ); + + } + + } ); + + Object.assign( Matrix4.prototype, { + + extractPosition: function ( m ) { + + console.warn( 'THREE.Matrix4: .extractPosition() has been renamed to .copyPosition().' ); + return this.copyPosition( m ); + + }, + flattenToArrayOffset: function ( array, offset ) { + + console.warn( "THREE.Matrix4: .flattenToArrayOffset() has been deprecated. Use .toArray() instead." ); + return this.toArray( array, offset ); + + }, + getPosition: function () { + + var v1; + + return function getPosition() { + + if ( v1 === undefined ) v1 = new Vector3(); + console.warn( 'THREE.Matrix4: .getPosition() has been removed. Use Vector3.setFromMatrixPosition( matrix ) instead.' ); + return v1.setFromMatrixColumn( this, 3 ); + + }; + + }(), + setRotationFromQuaternion: function ( q ) { + + console.warn( 'THREE.Matrix4: .setRotationFromQuaternion() has been renamed to .makeRotationFromQuaternion().' ); + return this.makeRotationFromQuaternion( q ); + + }, + multiplyToArray: function () { + + console.warn( 'THREE.Matrix4: .multiplyToArray() has been removed.' ); + + }, + multiplyVector3: function ( vector ) { + + console.warn( 'THREE.Matrix4: .multiplyVector3() has been removed. Use vector.applyMatrix4( matrix ) instead.' ); + return vector.applyMatrix4( this ); + + }, + multiplyVector4: function ( vector ) { + + console.warn( 'THREE.Matrix4: .multiplyVector4() has been removed. Use vector.applyMatrix4( matrix ) instead.' ); + return vector.applyMatrix4( this ); + + }, + multiplyVector3Array: function ( /* a */ ) { + + console.error( 'THREE.Matrix4: .multiplyVector3Array() has been removed.' ); + + }, + rotateAxis: function ( v ) { + + console.warn( 'THREE.Matrix4: .rotateAxis() has been removed. Use Vector3.transformDirection( matrix ) instead.' ); + v.transformDirection( this ); + + }, + crossVector: function ( vector ) { + + console.warn( 'THREE.Matrix4: .crossVector() has been removed. Use vector.applyMatrix4( matrix ) instead.' ); + return vector.applyMatrix4( this ); + + }, + translate: function () { + + console.error( 'THREE.Matrix4: .translate() has been removed.' ); + + }, + rotateX: function () { + + console.error( 'THREE.Matrix4: .rotateX() has been removed.' ); + + }, + rotateY: function () { + + console.error( 'THREE.Matrix4: .rotateY() has been removed.' ); + + }, + rotateZ: function () { + + console.error( 'THREE.Matrix4: .rotateZ() has been removed.' ); + + }, + rotateByAxis: function () { + + console.error( 'THREE.Matrix4: .rotateByAxis() has been removed.' ); + + }, + applyToBuffer: function ( buffer /*, offset, length */ ) { + + console.warn( 'THREE.Matrix4: .applyToBuffer() has been removed. Use matrix.applyToBufferAttribute( attribute ) instead.' ); + return this.applyToBufferAttribute( buffer ); + + }, + applyToVector3Array: function ( /* array, offset, length */ ) { + + console.error( 'THREE.Matrix4: .applyToVector3Array() has been removed.' ); + + }, + makeFrustum: function ( left, right, bottom, top, near, far ) { + + console.warn( 'THREE.Matrix4: .makeFrustum() has been removed. Use .makePerspective( left, right, top, bottom, near, far ) instead.' ); + return this.makePerspective( left, right, top, bottom, near, far ); + + } + + } ); + + Plane.prototype.isIntersectionLine = function ( line ) { + + console.warn( 'THREE.Plane: .isIntersectionLine() has been renamed to .intersectsLine().' ); + return this.intersectsLine( line ); + + }; + + Quaternion.prototype.multiplyVector3 = function ( vector ) { + + console.warn( 'THREE.Quaternion: .multiplyVector3() has been removed. Use is now vector.applyQuaternion( quaternion ) instead.' ); + return vector.applyQuaternion( this ); + + }; + + Object.assign( Ray.prototype, { + + isIntersectionBox: function ( box ) { + + console.warn( 'THREE.Ray: .isIntersectionBox() has been renamed to .intersectsBox().' ); + return this.intersectsBox( box ); + + }, + isIntersectionPlane: function ( plane ) { + + console.warn( 'THREE.Ray: .isIntersectionPlane() has been renamed to .intersectsPlane().' ); + return this.intersectsPlane( plane ); + + }, + isIntersectionSphere: function ( sphere ) { + + console.warn( 'THREE.Ray: .isIntersectionSphere() has been renamed to .intersectsSphere().' ); + return this.intersectsSphere( sphere ); + + } + + } ); + + Object.assign( Triangle.prototype, { + + area: function () { + + console.warn( 'THREE.Triangle: .area() has been renamed to .getArea().' ); + return this.getArea(); + + }, + barycoordFromPoint: function ( point, target ) { + + console.warn( 'THREE.Triangle: .barycoordFromPoint() has been renamed to .getBarycoord().' ); + return this.getBarycoord( point, target ); + + }, + midpoint: function ( target ) { + + console.warn( 'THREE.Triangle: .midpoint() has been renamed to .getMidpoint().' ); + return this.getMidpoint( target ); + + }, + normal: function ( target ) { + + console.warn( 'THREE.Triangle: .normal() has been renamed to .getNormal().' ); + return this.getNormal( target ); + + }, + plane: function ( target ) { + + console.warn( 'THREE.Triangle: .plane() has been renamed to .getPlane().' ); + return this.getPlane( target ); + + } + + } ); + + Object.assign( Triangle, { + + barycoordFromPoint: function ( point, a, b, c, target ) { + + console.warn( 'THREE.Triangle: .barycoordFromPoint() has been renamed to .getBarycoord().' ); + return Triangle.getBarycoord( point, a, b, c, target ); + + }, + normal: function ( a, b, c, target ) { + + console.warn( 'THREE.Triangle: .normal() has been renamed to .getNormal().' ); + return Triangle.getNormal( a, b, c, target ); + + } + + } ); + + Object.assign( Shape.prototype, { + + extractAllPoints: function ( divisions ) { + + console.warn( 'THREE.Shape: .extractAllPoints() has been removed. Use .extractPoints() instead.' ); + return this.extractPoints( divisions ); + + }, + extrude: function ( options ) { + + console.warn( 'THREE.Shape: .extrude() has been removed. Use ExtrudeGeometry() instead.' ); + return new ExtrudeGeometry( this, options ); + + }, + makeGeometry: function ( options ) { + + console.warn( 'THREE.Shape: .makeGeometry() has been removed. Use ShapeGeometry() instead.' ); + return new ShapeGeometry( this, options ); + + } + + } ); + + Object.assign( Vector2.prototype, { + + fromAttribute: function ( attribute, index, offset ) { + + console.warn( 'THREE.Vector2: .fromAttribute() has been renamed to .fromBufferAttribute().' ); + return this.fromBufferAttribute( attribute, index, offset ); + + }, + distanceToManhattan: function ( v ) { + + console.warn( 'THREE.Vector2: .distanceToManhattan() has been renamed to .manhattanDistanceTo().' ); + return this.manhattanDistanceTo( v ); + + }, + lengthManhattan: function () { + + console.warn( 'THREE.Vector2: .lengthManhattan() has been renamed to .manhattanLength().' ); + return this.manhattanLength(); + + } + + } ); + + Object.assign( Vector3.prototype, { + + setEulerFromRotationMatrix: function () { + + console.error( 'THREE.Vector3: .setEulerFromRotationMatrix() has been removed. Use Euler.setFromRotationMatrix() instead.' ); + + }, + setEulerFromQuaternion: function () { + + console.error( 'THREE.Vector3: .setEulerFromQuaternion() has been removed. Use Euler.setFromQuaternion() instead.' ); + + }, + getPositionFromMatrix: function ( m ) { + + console.warn( 'THREE.Vector3: .getPositionFromMatrix() has been renamed to .setFromMatrixPosition().' ); + return this.setFromMatrixPosition( m ); + + }, + getScaleFromMatrix: function ( m ) { + + console.warn( 'THREE.Vector3: .getScaleFromMatrix() has been renamed to .setFromMatrixScale().' ); + return this.setFromMatrixScale( m ); + + }, + getColumnFromMatrix: function ( index, matrix ) { + + console.warn( 'THREE.Vector3: .getColumnFromMatrix() has been renamed to .setFromMatrixColumn().' ); + return this.setFromMatrixColumn( matrix, index ); + + }, + applyProjection: function ( m ) { + + console.warn( 'THREE.Vector3: .applyProjection() has been removed. Use .applyMatrix4( m ) instead.' ); + return this.applyMatrix4( m ); + + }, + fromAttribute: function ( attribute, index, offset ) { + + console.warn( 'THREE.Vector3: .fromAttribute() has been renamed to .fromBufferAttribute().' ); + return this.fromBufferAttribute( attribute, index, offset ); + + }, + distanceToManhattan: function ( v ) { + + console.warn( 'THREE.Vector3: .distanceToManhattan() has been renamed to .manhattanDistanceTo().' ); + return this.manhattanDistanceTo( v ); + + }, + lengthManhattan: function () { + + console.warn( 'THREE.Vector3: .lengthManhattan() has been renamed to .manhattanLength().' ); + return this.manhattanLength(); + + } + + } ); + + Object.assign( Vector4.prototype, { + + fromAttribute: function ( attribute, index, offset ) { + + console.warn( 'THREE.Vector4: .fromAttribute() has been renamed to .fromBufferAttribute().' ); + return this.fromBufferAttribute( attribute, index, offset ); + + }, + lengthManhattan: function () { + + console.warn( 'THREE.Vector4: .lengthManhattan() has been renamed to .manhattanLength().' ); + return this.manhattanLength(); + + } + + } ); + + // + + Object.assign( Geometry.prototype, { + + computeTangents: function () { + + console.error( 'THREE.Geometry: .computeTangents() has been removed.' ); + + }, + computeLineDistances: function () { + + console.error( 'THREE.Geometry: .computeLineDistances() has been removed. Use THREE.Line.computeLineDistances() instead.' ); + + } + + } ); + + Object.assign( Object3D.prototype, { + + getChildByName: function ( name ) { + + console.warn( 'THREE.Object3D: .getChildByName() has been renamed to .getObjectByName().' ); + return this.getObjectByName( name ); + + }, + renderDepth: function () { + + console.warn( 'THREE.Object3D: .renderDepth has been removed. Use .renderOrder, instead.' ); + + }, + translate: function ( distance, axis ) { + + console.warn( 'THREE.Object3D: .translate() has been removed. Use .translateOnAxis( axis, distance ) instead.' ); + return this.translateOnAxis( axis, distance ); + + }, + getWorldRotation: function () { + + console.error( 'THREE.Object3D: .getWorldRotation() has been removed. Use THREE.Object3D.getWorldQuaternion( target ) instead.' ); + + } + + } ); + + Object.defineProperties( Object3D.prototype, { + + eulerOrder: { + get: function () { + + console.warn( 'THREE.Object3D: .eulerOrder is now .rotation.order.' ); + return this.rotation.order; + + }, + set: function ( value ) { + + console.warn( 'THREE.Object3D: .eulerOrder is now .rotation.order.' ); + this.rotation.order = value; + + } + }, + useQuaternion: { + get: function () { + + console.warn( 'THREE.Object3D: .useQuaternion has been removed. The library now uses quaternions by default.' ); + + }, + set: function () { + + console.warn( 'THREE.Object3D: .useQuaternion has been removed. The library now uses quaternions by default.' ); + + } + } + + } ); + + Object.defineProperties( LOD.prototype, { + + objects: { + get: function () { + + console.warn( 'THREE.LOD: .objects has been renamed to .levels.' ); + return this.levels; + + } + } + + } ); + + Object.defineProperty( Skeleton.prototype, 'useVertexTexture', { + + get: function () { + + console.warn( 'THREE.Skeleton: useVertexTexture has been removed.' ); + + }, + set: function () { + + console.warn( 'THREE.Skeleton: useVertexTexture has been removed.' ); + + } + + } ); + + Object.defineProperty( Curve.prototype, '__arcLengthDivisions', { + + get: function () { + + console.warn( 'THREE.Curve: .__arcLengthDivisions is now .arcLengthDivisions.' ); + return this.arcLengthDivisions; + + }, + set: function ( value ) { + + console.warn( 'THREE.Curve: .__arcLengthDivisions is now .arcLengthDivisions.' ); + this.arcLengthDivisions = value; + + } + + } ); + + // + + PerspectiveCamera.prototype.setLens = function ( focalLength, filmGauge ) { + + console.warn( "THREE.PerspectiveCamera.setLens is deprecated. " + + "Use .setFocalLength and .filmGauge for a photographic setup." ); + + if ( filmGauge !== undefined ) this.filmGauge = filmGauge; + this.setFocalLength( focalLength ); + + }; + + // + + Object.defineProperties( Light.prototype, { + onlyShadow: { + set: function () { + + console.warn( 'THREE.Light: .onlyShadow has been removed.' ); + + } + }, + shadowCameraFov: { + set: function ( value ) { + + console.warn( 'THREE.Light: .shadowCameraFov is now .shadow.camera.fov.' ); + this.shadow.camera.fov = value; + + } + }, + shadowCameraLeft: { + set: function ( value ) { + + console.warn( 'THREE.Light: .shadowCameraLeft is now .shadow.camera.left.' ); + this.shadow.camera.left = value; + + } + }, + shadowCameraRight: { + set: function ( value ) { + + console.warn( 'THREE.Light: .shadowCameraRight is now .shadow.camera.right.' ); + this.shadow.camera.right = value; + + } + }, + shadowCameraTop: { + set: function ( value ) { + + console.warn( 'THREE.Light: .shadowCameraTop is now .shadow.camera.top.' ); + this.shadow.camera.top = value; + + } + }, + shadowCameraBottom: { + set: function ( value ) { + + console.warn( 'THREE.Light: .shadowCameraBottom is now .shadow.camera.bottom.' ); + this.shadow.camera.bottom = value; + + } + }, + shadowCameraNear: { + set: function ( value ) { + + console.warn( 'THREE.Light: .shadowCameraNear is now .shadow.camera.near.' ); + this.shadow.camera.near = value; + + } + }, + shadowCameraFar: { + set: function ( value ) { + + console.warn( 'THREE.Light: .shadowCameraFar is now .shadow.camera.far.' ); + this.shadow.camera.far = value; + + } + }, + shadowCameraVisible: { + set: function () { + + console.warn( 'THREE.Light: .shadowCameraVisible has been removed. Use new THREE.CameraHelper( light.shadow.camera ) instead.' ); + + } + }, + shadowBias: { + set: function ( value ) { + + console.warn( 'THREE.Light: .shadowBias is now .shadow.bias.' ); + this.shadow.bias = value; + + } + }, + shadowDarkness: { + set: function () { + + console.warn( 'THREE.Light: .shadowDarkness has been removed.' ); + + } + }, + shadowMapWidth: { + set: function ( value ) { + + console.warn( 'THREE.Light: .shadowMapWidth is now .shadow.mapSize.width.' ); + this.shadow.mapSize.width = value; + + } + }, + shadowMapHeight: { + set: function ( value ) { + + console.warn( 'THREE.Light: .shadowMapHeight is now .shadow.mapSize.height.' ); + this.shadow.mapSize.height = value; + + } + } + } ); + + // + + Object.defineProperties( BufferAttribute.prototype, { + + length: { + get: function () { + + console.warn( 'THREE.BufferAttribute: .length has been deprecated. Use .count instead.' ); + return this.array.length; + + } + }, + copyIndicesArray: function ( /* indices */ ) { + + console.error( 'THREE.BufferAttribute: .copyIndicesArray() has been removed.' ); + + } + + } ); + + Object.assign( BufferGeometry.prototype, { + + addIndex: function ( index ) { + + console.warn( 'THREE.BufferGeometry: .addIndex() has been renamed to .setIndex().' ); + this.setIndex( index ); + + }, + addDrawCall: function ( start, count, indexOffset ) { + + if ( indexOffset !== undefined ) { + + console.warn( 'THREE.BufferGeometry: .addDrawCall() no longer supports indexOffset.' ); + + } + console.warn( 'THREE.BufferGeometry: .addDrawCall() is now .addGroup().' ); + this.addGroup( start, count ); + + }, + clearDrawCalls: function () { + + console.warn( 'THREE.BufferGeometry: .clearDrawCalls() is now .clearGroups().' ); + this.clearGroups(); + + }, + computeTangents: function () { + + console.warn( 'THREE.BufferGeometry: .computeTangents() has been removed.' ); + + }, + computeOffsets: function () { + + console.warn( 'THREE.BufferGeometry: .computeOffsets() has been removed.' ); + + } + + } ); + + Object.defineProperties( BufferGeometry.prototype, { + + drawcalls: { + get: function () { + + console.error( 'THREE.BufferGeometry: .drawcalls has been renamed to .groups.' ); + return this.groups; + + } + }, + offsets: { + get: function () { + + console.warn( 'THREE.BufferGeometry: .offsets has been renamed to .groups.' ); + return this.groups; + + } + } + + } ); + + // + + Object.assign( ExtrudeBufferGeometry.prototype, { + + getArrays: function () { + + console.error( 'THREE.ExtrudeBufferGeometry: .getArrays() has been removed.' ); + + }, + + addShapeList: function () { + + console.error( 'THREE.ExtrudeBufferGeometry: .addShapeList() has been removed.' ); + + }, + + addShape: function () { + + console.error( 'THREE.ExtrudeBufferGeometry: .addShape() has been removed.' ); + + } + + } ); + + // + + Object.defineProperties( Uniform.prototype, { + + dynamic: { + set: function () { + + console.warn( 'THREE.Uniform: .dynamic has been removed. Use object.onBeforeRender() instead.' ); + + } + }, + onUpdate: { + value: function () { + + console.warn( 'THREE.Uniform: .onUpdate() has been removed. Use object.onBeforeRender() instead.' ); + return this; + + } + } + + } ); + + // + + Object.defineProperties( Material.prototype, { + + wrapAround: { + get: function () { + + console.warn( 'THREE.Material: .wrapAround has been removed.' ); + + }, + set: function () { + + console.warn( 'THREE.Material: .wrapAround has been removed.' ); + + } + }, + wrapRGB: { + get: function () { + + console.warn( 'THREE.Material: .wrapRGB has been removed.' ); + return new Color(); + + } + }, + + shading: { + get: function () { + + console.error( 'THREE.' + this.type + ': .shading has been removed. Use the boolean .flatShading instead.' ); + + }, + set: function ( value ) { + + console.warn( 'THREE.' + this.type + ': .shading has been removed. Use the boolean .flatShading instead.' ); + this.flatShading = ( value === FlatShading ); + + } + } + + } ); + + Object.defineProperties( MeshPhongMaterial.prototype, { + + metal: { + get: function () { + + console.warn( 'THREE.MeshPhongMaterial: .metal has been removed. Use THREE.MeshStandardMaterial instead.' ); + return false; + + }, + set: function () { + + console.warn( 'THREE.MeshPhongMaterial: .metal has been removed. Use THREE.MeshStandardMaterial instead' ); + + } + } + + } ); + + Object.defineProperties( ShaderMaterial.prototype, { + + derivatives: { + get: function () { + + console.warn( 'THREE.ShaderMaterial: .derivatives has been moved to .extensions.derivatives.' ); + return this.extensions.derivatives; + + }, + set: function ( value ) { + + console.warn( 'THREE. ShaderMaterial: .derivatives has been moved to .extensions.derivatives.' ); + this.extensions.derivatives = value; + + } + } + + } ); + + // + + Object.assign( WebGLRenderer.prototype, { + + animate: function ( callback ) { + + console.warn( 'THREE.WebGLRenderer: .animate() is now .setAnimationLoop().' ); + this.setAnimationLoop( callback ); + + }, + + getCurrentRenderTarget: function () { + + console.warn( 'THREE.WebGLRenderer: .getCurrentRenderTarget() is now .getRenderTarget().' ); + return this.getRenderTarget(); + + }, + + getMaxAnisotropy: function () { + + console.warn( 'THREE.WebGLRenderer: .getMaxAnisotropy() is now .capabilities.getMaxAnisotropy().' ); + return this.capabilities.getMaxAnisotropy(); + + }, + + getPrecision: function () { + + console.warn( 'THREE.WebGLRenderer: .getPrecision() is now .capabilities.precision.' ); + return this.capabilities.precision; + + }, + + resetGLState: function () { + + console.warn( 'THREE.WebGLRenderer: .resetGLState() is now .state.reset().' ); + return this.state.reset(); + + }, + + supportsFloatTextures: function () { + + console.warn( 'THREE.WebGLRenderer: .supportsFloatTextures() is now .extensions.get( \'OES_texture_float\' ).' ); + return this.extensions.get( 'OES_texture_float' ); + + }, + supportsHalfFloatTextures: function () { + + console.warn( 'THREE.WebGLRenderer: .supportsHalfFloatTextures() is now .extensions.get( \'OES_texture_half_float\' ).' ); + return this.extensions.get( 'OES_texture_half_float' ); + + }, + supportsStandardDerivatives: function () { + + console.warn( 'THREE.WebGLRenderer: .supportsStandardDerivatives() is now .extensions.get( \'OES_standard_derivatives\' ).' ); + return this.extensions.get( 'OES_standard_derivatives' ); + + }, + supportsCompressedTextureS3TC: function () { + + console.warn( 'THREE.WebGLRenderer: .supportsCompressedTextureS3TC() is now .extensions.get( \'WEBGL_compressed_texture_s3tc\' ).' ); + return this.extensions.get( 'WEBGL_compressed_texture_s3tc' ); + + }, + supportsCompressedTexturePVRTC: function () { + + console.warn( 'THREE.WebGLRenderer: .supportsCompressedTexturePVRTC() is now .extensions.get( \'WEBGL_compressed_texture_pvrtc\' ).' ); + return this.extensions.get( 'WEBGL_compressed_texture_pvrtc' ); + + }, + supportsBlendMinMax: function () { + + console.warn( 'THREE.WebGLRenderer: .supportsBlendMinMax() is now .extensions.get( \'EXT_blend_minmax\' ).' ); + return this.extensions.get( 'EXT_blend_minmax' ); + + }, + supportsVertexTextures: function () { + + console.warn( 'THREE.WebGLRenderer: .supportsVertexTextures() is now .capabilities.vertexTextures.' ); + return this.capabilities.vertexTextures; + + }, + supportsInstancedArrays: function () { + + console.warn( 'THREE.WebGLRenderer: .supportsInstancedArrays() is now .extensions.get( \'ANGLE_instanced_arrays\' ).' ); + return this.extensions.get( 'ANGLE_instanced_arrays' ); + + }, + enableScissorTest: function ( boolean ) { + + console.warn( 'THREE.WebGLRenderer: .enableScissorTest() is now .setScissorTest().' ); + this.setScissorTest( boolean ); + + }, + initMaterial: function () { + + console.warn( 'THREE.WebGLRenderer: .initMaterial() has been removed.' ); + + }, + addPrePlugin: function () { + + console.warn( 'THREE.WebGLRenderer: .addPrePlugin() has been removed.' ); + + }, + addPostPlugin: function () { + + console.warn( 'THREE.WebGLRenderer: .addPostPlugin() has been removed.' ); + + }, + updateShadowMap: function () { + + console.warn( 'THREE.WebGLRenderer: .updateShadowMap() has been removed.' ); + + }, + setFaceCulling: function () { + + console.warn( 'THREE.WebGLRenderer: .setFaceCulling() has been removed.' ); + + } + + } ); + + Object.defineProperties( WebGLRenderer.prototype, { + + shadowMapEnabled: { + get: function () { + + return this.shadowMap.enabled; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderer: .shadowMapEnabled is now .shadowMap.enabled.' ); + this.shadowMap.enabled = value; + + } + }, + shadowMapType: { + get: function () { + + return this.shadowMap.type; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderer: .shadowMapType is now .shadowMap.type.' ); + this.shadowMap.type = value; + + } + }, + shadowMapCullFace: { + get: function () { + + console.warn( 'THREE.WebGLRenderer: .shadowMapCullFace has been removed. Set Material.shadowSide instead.' ); + return undefined; + + }, + set: function ( /* value */ ) { + + console.warn( 'THREE.WebGLRenderer: .shadowMapCullFace has been removed. Set Material.shadowSide instead.' ); + + } + } + } ); + + Object.defineProperties( WebGLShadowMap.prototype, { + + cullFace: { + get: function () { + + console.warn( 'THREE.WebGLRenderer: .shadowMap.cullFace has been removed. Set Material.shadowSide instead.' ); + return undefined; + + }, + set: function ( /* cullFace */ ) { + + console.warn( 'THREE.WebGLRenderer: .shadowMap.cullFace has been removed. Set Material.shadowSide instead.' ); + + } + }, + renderReverseSided: { + get: function () { + + console.warn( 'THREE.WebGLRenderer: .shadowMap.renderReverseSided has been removed. Set Material.shadowSide instead.' ); + return undefined; + + }, + set: function () { + + console.warn( 'THREE.WebGLRenderer: .shadowMap.renderReverseSided has been removed. Set Material.shadowSide instead.' ); + + } + }, + renderSingleSided: { + get: function () { + + console.warn( 'THREE.WebGLRenderer: .shadowMap.renderSingleSided has been removed. Set Material.shadowSide instead.' ); + return undefined; + + }, + set: function () { + + console.warn( 'THREE.WebGLRenderer: .shadowMap.renderSingleSided has been removed. Set Material.shadowSide instead.' ); + + } + } + + } ); + + // + + Object.defineProperties( WebGLRenderTarget.prototype, { + + wrapS: { + get: function () { + + console.warn( 'THREE.WebGLRenderTarget: .wrapS is now .texture.wrapS.' ); + return this.texture.wrapS; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderTarget: .wrapS is now .texture.wrapS.' ); + this.texture.wrapS = value; + + } + }, + wrapT: { + get: function () { + + console.warn( 'THREE.WebGLRenderTarget: .wrapT is now .texture.wrapT.' ); + return this.texture.wrapT; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderTarget: .wrapT is now .texture.wrapT.' ); + this.texture.wrapT = value; + + } + }, + magFilter: { + get: function () { + + console.warn( 'THREE.WebGLRenderTarget: .magFilter is now .texture.magFilter.' ); + return this.texture.magFilter; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderTarget: .magFilter is now .texture.magFilter.' ); + this.texture.magFilter = value; + + } + }, + minFilter: { + get: function () { + + console.warn( 'THREE.WebGLRenderTarget: .minFilter is now .texture.minFilter.' ); + return this.texture.minFilter; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderTarget: .minFilter is now .texture.minFilter.' ); + this.texture.minFilter = value; + + } + }, + anisotropy: { + get: function () { + + console.warn( 'THREE.WebGLRenderTarget: .anisotropy is now .texture.anisotropy.' ); + return this.texture.anisotropy; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderTarget: .anisotropy is now .texture.anisotropy.' ); + this.texture.anisotropy = value; + + } + }, + offset: { + get: function () { + + console.warn( 'THREE.WebGLRenderTarget: .offset is now .texture.offset.' ); + return this.texture.offset; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderTarget: .offset is now .texture.offset.' ); + this.texture.offset = value; + + } + }, + repeat: { + get: function () { + + console.warn( 'THREE.WebGLRenderTarget: .repeat is now .texture.repeat.' ); + return this.texture.repeat; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderTarget: .repeat is now .texture.repeat.' ); + this.texture.repeat = value; + + } + }, + format: { + get: function () { + + console.warn( 'THREE.WebGLRenderTarget: .format is now .texture.format.' ); + return this.texture.format; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderTarget: .format is now .texture.format.' ); + this.texture.format = value; + + } + }, + type: { + get: function () { + + console.warn( 'THREE.WebGLRenderTarget: .type is now .texture.type.' ); + return this.texture.type; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderTarget: .type is now .texture.type.' ); + this.texture.type = value; + + } + }, + generateMipmaps: { + get: function () { + + console.warn( 'THREE.WebGLRenderTarget: .generateMipmaps is now .texture.generateMipmaps.' ); + return this.texture.generateMipmaps; + + }, + set: function ( value ) { + + console.warn( 'THREE.WebGLRenderTarget: .generateMipmaps is now .texture.generateMipmaps.' ); + this.texture.generateMipmaps = value; + + } + } + + } ); + + // + + Object.defineProperties( WebVRManager.prototype, { + + standing: { + set: function ( /* value */ ) { + + console.warn( 'THREE.WebVRManager: .standing has been removed.' ); + + } + } + + } ); + + // + + Audio.prototype.load = function ( file ) { + + console.warn( 'THREE.Audio: .load has been deprecated. Use THREE.AudioLoader instead.' ); + var scope = this; + var audioLoader = new AudioLoader(); + audioLoader.load( file, function ( buffer ) { + + scope.setBuffer( buffer ); + + } ); + return this; + + }; + + AudioAnalyser.prototype.getData = function () { + + console.warn( 'THREE.AudioAnalyser: .getData() is now .getFrequencyData().' ); + return this.getFrequencyData(); + + }; + + // + + CubeCamera.prototype.updateCubeMap = function ( renderer, scene ) { + + console.warn( 'THREE.CubeCamera: .updateCubeMap() is now .update().' ); + return this.update( renderer, scene ); + + }; + + /** + * @author dmarcos / https://github.com/dmarcos + * @author mrdoob / http://mrdoob.com + */ + var VRControls = function ( object, onError ) { + + var scope = this; + + var vrDisplay, vrDisplays; + + var standingMatrix = new Matrix4(); + + var frameData = null; + + if ( 'VRFrameData' in window ) { + + frameData = new VRFrameData(); + + } + + function gotVRDisplays( displays ) { + + vrDisplays = displays; + + if ( displays.length > 0 ) { + + vrDisplay = displays[ 0 ]; + + } else { + + if ( onError ) onError( 'VR input not available.' ); + + } + + } + + if ( navigator.getVRDisplays ) { + + navigator.getVRDisplays().then( gotVRDisplays ).catch( function () { + + console.warn( 'VRControls: Unable to get VR Displays' ); + + } ); + + } + + // the Rift SDK returns the position in meters + // this scale factor allows the user to define how meters + // are converted to scene units. + + this.scale = 1; + + // If true will use "standing space" coordinate system where y=0 is the + // floor and x=0, z=0 is the center of the room. + this.standing = false; + + // Distance from the users eyes to the floor in meters. Used when + // standing=true but the VRDisplay doesn't provide stageParameters. + this.userHeight = 1.6; + + this.getVRDisplay = function () { + + return vrDisplay; + + }; + + this.setVRDisplay = function ( value ) { + + vrDisplay = value; + + }; + + this.getVRDisplays = function () { + + console.warn( 'VRControls: getVRDisplays() is being deprecated.' ); + return vrDisplays; + + }; + + this.getStandingMatrix = function () { + + return standingMatrix; + + }; + + this.update = function () { + + if ( vrDisplay ) { + + var pose; + + if ( vrDisplay.getFrameData ) { + + vrDisplay.getFrameData( frameData ); + pose = frameData.pose; + + } else if ( vrDisplay.getPose ) { + + pose = vrDisplay.getPose(); + + } + + if ( pose.orientation !== null ) { + + object.quaternion.fromArray( pose.orientation ); + + } + + if ( pose.position !== null ) { + + object.position.fromArray( pose.position ); + + } else { + + object.position.set( 0, 0, 0 ); + + } + + if ( this.standing ) { + + if ( vrDisplay.stageParameters ) { + + object.updateMatrix(); + + standingMatrix.fromArray( vrDisplay.stageParameters.sittingToStandingTransform ); + object.applyMatrix( standingMatrix ); + + } else { + + object.position.setY( object.position.y + this.userHeight ); + + } + + } + + object.position.multiplyScalar( scope.scale ); + + } + + }; + + this.dispose = function () { + + vrDisplay = null; + + }; + + }; + + /** + * @author dmarcos / https://github.com/dmarcos + * @author mrdoob / http://mrdoob.com + * + * WebVR Spec: http://mozvr.github.io/webvr-spec/webvr.html + * + * Firefox: http://mozvr.com/downloads/ + * Chromium: https://webvr.info/get-chrome + */ + var VREffect = function ( renderer, onError ) { + + var vrDisplay, vrDisplays; + var eyeTranslationL = new Vector3(); + var eyeTranslationR = new Vector3(); + var renderRectL, renderRectR; + var headMatrix = new Matrix4(); + var eyeMatrixL = new Matrix4(); + var eyeMatrixR = new Matrix4(); + + var frameData = null; + + if ( 'VRFrameData' in window ) { + + frameData = new window.VRFrameData(); + + } + + function gotVRDisplays( displays ) { + + vrDisplays = displays; + + if ( displays.length > 0 ) { + + vrDisplay = displays[ 0 ]; + + } else { + + if ( onError ) onError( 'HMD not available' ); + + } + + } + + if ( navigator.getVRDisplays ) { + + navigator.getVRDisplays().then( gotVRDisplays ).catch( function () { + + console.warn( 'VREffect: Unable to get VR Displays' ); + + } ); + + } + + // + + this.isPresenting = false; + + var scope = this; + + var rendererSize = renderer.getSize(); + var rendererUpdateStyle = false; + var rendererPixelRatio = renderer.getPixelRatio(); + + this.getVRDisplay = function () { + + return vrDisplay; + + }; + + this.setVRDisplay = function ( value ) { + + vrDisplay = value; + + }; + + this.getVRDisplays = function () { + + console.warn( 'VREffect: getVRDisplays() is being deprecated.' ); + return vrDisplays; + + }; + + this.setSize = function ( width, height, updateStyle ) { + + rendererSize = { width: width, height: height }; + rendererUpdateStyle = updateStyle; + + if ( scope.isPresenting ) { + + var eyeParamsL = vrDisplay.getEyeParameters( 'left' ); + renderer.setPixelRatio( 1 ); + renderer.setSize( eyeParamsL.renderWidth * 2, eyeParamsL.renderHeight, false ); + + } else { + + renderer.setPixelRatio( rendererPixelRatio ); + renderer.setSize( width, height, updateStyle ); + + } + + }; + + // VR presentation + + var canvas = renderer.domElement; + var defaultLeftBounds = [ 0.0, 0.0, 0.5, 1.0 ]; + var defaultRightBounds = [ 0.5, 0.0, 0.5, 1.0 ]; + + function onVRDisplayPresentChange() { + + var wasPresenting = scope.isPresenting; + scope.isPresenting = vrDisplay !== undefined && vrDisplay.isPresenting; + + if ( scope.isPresenting ) { + + var eyeParamsL = vrDisplay.getEyeParameters( 'left' ); + var eyeWidth = eyeParamsL.renderWidth; + var eyeHeight = eyeParamsL.renderHeight; + + if ( ! wasPresenting ) { + + rendererPixelRatio = renderer.getPixelRatio(); + rendererSize = renderer.getSize(); + + renderer.setPixelRatio( 1 ); + renderer.setSize( eyeWidth * 2, eyeHeight, false ); + + } + + } else if ( wasPresenting ) { + + renderer.setPixelRatio( rendererPixelRatio ); + renderer.setSize( rendererSize.width, rendererSize.height, rendererUpdateStyle ); + + } + + } + + window.addEventListener( 'vrdisplaypresentchange', onVRDisplayPresentChange, false ); + + this.setFullScreen = function ( boolean ) { + + return new Promise( function ( resolve, reject ) { + + if ( vrDisplay === undefined ) { + + reject( new Error( 'No VR hardware found.' ) ); + return; + + } + + if ( scope.isPresenting === boolean ) { + + resolve(); + return; + + } + + if ( boolean ) { + + resolve( vrDisplay.requestPresent( [ { source: canvas } ] ) ); + + } else { + + resolve( vrDisplay.exitPresent() ); + + } + + } ); + + }; + + this.requestPresent = function () { + + return this.setFullScreen( true ); + + }; + + this.exitPresent = function () { + + return this.setFullScreen( false ); + + }; + + this.requestAnimationFrame = function ( f ) { + + if ( vrDisplay !== undefined ) { + + return vrDisplay.requestAnimationFrame( f ); + + } else { + + return window.requestAnimationFrame( f ); + + } + + }; + + this.cancelAnimationFrame = function ( h ) { + + if ( vrDisplay !== undefined ) { + + vrDisplay.cancelAnimationFrame( h ); + + } else { + + window.cancelAnimationFrame( h ); + + } + + }; + + this.submitFrame = function () { + + if ( vrDisplay !== undefined && scope.isPresenting ) { + + vrDisplay.submitFrame(); + + } + + }; + + this.autoSubmitFrame = true; + + // render + + var cameraL = new PerspectiveCamera(); + cameraL.layers.enable( 1 ); + + var cameraR = new PerspectiveCamera(); + cameraR.layers.enable( 2 ); + + this.render = function ( scene, camera, renderTarget, forceClear ) { + + if ( vrDisplay && scope.isPresenting ) { + + var autoUpdate = scene.autoUpdate; + + if ( autoUpdate ) { + + scene.updateMatrixWorld(); + scene.autoUpdate = false; + + } + + if ( Array.isArray( scene ) ) { + + console.warn( 'VREffect.render() no longer supports arrays. Use object.layers instead.' ); + scene = scene[ 0 ]; + + } + + // When rendering we don't care what the recommended size is, only what the actual size + // of the backbuffer is. + var size = renderer.getSize(); + var layers = vrDisplay.getLayers(); + var leftBounds; + var rightBounds; + + if ( layers.length ) { + + var layer = layers[ 0 ]; + + leftBounds = layer.leftBounds !== null && layer.leftBounds.length === 4 ? layer.leftBounds : defaultLeftBounds; + rightBounds = layer.rightBounds !== null && layer.rightBounds.length === 4 ? layer.rightBounds : defaultRightBounds; + + } else { + + leftBounds = defaultLeftBounds; + rightBounds = defaultRightBounds; + + } + + renderRectL = { + x: Math.round( size.width * leftBounds[ 0 ] ), + y: Math.round( size.height * leftBounds[ 1 ] ), + width: Math.round( size.width * leftBounds[ 2 ] ), + height: Math.round( size.height * leftBounds[ 3 ] ) + }; + renderRectR = { + x: Math.round( size.width * rightBounds[ 0 ] ), + y: Math.round( size.height * rightBounds[ 1 ] ), + width: Math.round( size.width * rightBounds[ 2 ] ), + height: Math.round( size.height * rightBounds[ 3 ] ) + }; + + if ( renderTarget ) { + + renderer.setRenderTarget( renderTarget ); + renderTarget.scissorTest = true; + + } else { + + renderer.setRenderTarget( null ); + renderer.setScissorTest( true ); + + } + + if ( renderer.autoClear || forceClear ) renderer.clear(); + + if ( camera.parent === null ) camera.updateMatrixWorld(); + + camera.matrixWorld.decompose( cameraL.position, cameraL.quaternion, cameraL.scale ); + + cameraR.position.copy( cameraL.position ); + cameraR.quaternion.copy( cameraL.quaternion ); + cameraR.scale.copy( cameraL.scale ); + + if ( vrDisplay.getFrameData ) { + + vrDisplay.depthNear = camera.near; + vrDisplay.depthFar = camera.far; + + vrDisplay.getFrameData( frameData ); + + cameraL.projectionMatrix.elements = frameData.leftProjectionMatrix; + cameraR.projectionMatrix.elements = frameData.rightProjectionMatrix; + + getEyeMatrices( frameData ); + + cameraL.updateMatrix(); + cameraL.matrix.multiply( eyeMatrixL ); + cameraL.matrix.decompose( cameraL.position, cameraL.quaternion, cameraL.scale ); + + cameraR.updateMatrix(); + cameraR.matrix.multiply( eyeMatrixR ); + cameraR.matrix.decompose( cameraR.position, cameraR.quaternion, cameraR.scale ); + + } else { + + var eyeParamsL = vrDisplay.getEyeParameters( 'left' ); + var eyeParamsR = vrDisplay.getEyeParameters( 'right' ); + + cameraL.projectionMatrix = fovToProjection( eyeParamsL.fieldOfView, true, camera.near, camera.far ); + cameraR.projectionMatrix = fovToProjection( eyeParamsR.fieldOfView, true, camera.near, camera.far ); + + eyeTranslationL.fromArray( eyeParamsL.offset ); + eyeTranslationR.fromArray( eyeParamsR.offset ); + + cameraL.translateOnAxis( eyeTranslationL, cameraL.scale.x ); + cameraR.translateOnAxis( eyeTranslationR, cameraR.scale.x ); + + } + + // render left eye + if ( renderTarget ) { + + renderTarget.viewport.set( renderRectL.x, renderRectL.y, renderRectL.width, renderRectL.height ); + renderTarget.scissor.set( renderRectL.x, renderRectL.y, renderRectL.width, renderRectL.height ); + + } else { + + renderer.setViewport( renderRectL.x, renderRectL.y, renderRectL.width, renderRectL.height ); + renderer.setScissor( renderRectL.x, renderRectL.y, renderRectL.width, renderRectL.height ); + + } + renderer.render( scene, cameraL, renderTarget, forceClear ); + + // render right eye + if ( renderTarget ) { + + renderTarget.viewport.set( renderRectR.x, renderRectR.y, renderRectR.width, renderRectR.height ); + renderTarget.scissor.set( renderRectR.x, renderRectR.y, renderRectR.width, renderRectR.height ); + + } else { + + renderer.setViewport( renderRectR.x, renderRectR.y, renderRectR.width, renderRectR.height ); + renderer.setScissor( renderRectR.x, renderRectR.y, renderRectR.width, renderRectR.height ); + + } + renderer.render( scene, cameraR, renderTarget, forceClear ); + + if ( renderTarget ) { + + renderTarget.viewport.set( 0, 0, size.width, size.height ); + renderTarget.scissor.set( 0, 0, size.width, size.height ); + renderTarget.scissorTest = false; + renderer.setRenderTarget( null ); + + } else { + + renderer.setViewport( 0, 0, size.width, size.height ); + renderer.setScissorTest( false ); + + } + + if ( autoUpdate ) { + + scene.autoUpdate = true; + + } + + if ( scope.autoSubmitFrame ) { + + scope.submitFrame(); + + } + + return; + + } + + // Regular render mode if not HMD + + renderer.render( scene, camera, renderTarget, forceClear ); + + }; + + this.dispose = function () { + + window.removeEventListener( 'vrdisplaypresentchange', onVRDisplayPresentChange, false ); + + }; + + // + + var poseOrientation = new Quaternion(); + var posePosition = new Vector3(); + + // Compute model matrices of the eyes with respect to the head. + function getEyeMatrices( frameData ) { + + // Compute the matrix for the position of the head based on the pose + if ( frameData.pose.orientation ) { + + poseOrientation.fromArray( frameData.pose.orientation ); + headMatrix.makeRotationFromQuaternion( poseOrientation ); + + } else { + + headMatrix.identity(); + + } + + if ( frameData.pose.position ) { + + posePosition.fromArray( frameData.pose.position ); + headMatrix.setPosition( posePosition ); + + } + + // The view matrix transforms vertices from sitting space to eye space. As such, the view matrix can be thought of as a product of two matrices: + // headToEyeMatrix * sittingToHeadMatrix + + // The headMatrix that we've calculated above is the model matrix of the head in sitting space, which is the inverse of sittingToHeadMatrix. + // So when we multiply the view matrix with headMatrix, we're left with headToEyeMatrix: + // viewMatrix * headMatrix = headToEyeMatrix * sittingToHeadMatrix * headMatrix = headToEyeMatrix + + eyeMatrixL.fromArray( frameData.leftViewMatrix ); + eyeMatrixL.multiply( headMatrix ); + eyeMatrixR.fromArray( frameData.rightViewMatrix ); + eyeMatrixR.multiply( headMatrix ); + + // The eye's model matrix in head space is the inverse of headToEyeMatrix we calculated above. + + eyeMatrixL.getInverse( eyeMatrixL ); + eyeMatrixR.getInverse( eyeMatrixR ); + + } + + function fovToNDCScaleOffset( fov ) { + + var pxscale = 2.0 / ( fov.leftTan + fov.rightTan ); + var pxoffset = ( fov.leftTan - fov.rightTan ) * pxscale * 0.5; + var pyscale = 2.0 / ( fov.upTan + fov.downTan ); + var pyoffset = ( fov.upTan - fov.downTan ) * pyscale * 0.5; + return { scale: [ pxscale, pyscale ], offset: [ pxoffset, pyoffset ] }; + + } + + function fovPortToProjection( fov, rightHanded, zNear, zFar ) { + + rightHanded = rightHanded === undefined ? true : rightHanded; + zNear = zNear === undefined ? 0.01 : zNear; + zFar = zFar === undefined ? 10000.0 : zFar; + + var handednessScale = rightHanded ? - 1.0 : 1.0; + + // start with an identity matrix + var mobj = new Matrix4(); + var m = mobj.elements; + + // and with scale/offset info for normalized device coords + var scaleAndOffset = fovToNDCScaleOffset( fov ); + + // X result, map clip edges to [-w,+w] + m[ 0 * 4 + 0 ] = scaleAndOffset.scale[ 0 ]; + m[ 0 * 4 + 1 ] = 0.0; + m[ 0 * 4 + 2 ] = scaleAndOffset.offset[ 0 ] * handednessScale; + m[ 0 * 4 + 3 ] = 0.0; + + // Y result, map clip edges to [-w,+w] + // Y offset is negated because this proj matrix transforms from world coords with Y=up, + // but the NDC scaling has Y=down (thanks D3D?) + m[ 1 * 4 + 0 ] = 0.0; + m[ 1 * 4 + 1 ] = scaleAndOffset.scale[ 1 ]; + m[ 1 * 4 + 2 ] = - scaleAndOffset.offset[ 1 ] * handednessScale; + m[ 1 * 4 + 3 ] = 0.0; + + // Z result (up to the app) + m[ 2 * 4 + 0 ] = 0.0; + m[ 2 * 4 + 1 ] = 0.0; + m[ 2 * 4 + 2 ] = zFar / ( zNear - zFar ) * - handednessScale; + m[ 2 * 4 + 3 ] = ( zFar * zNear ) / ( zNear - zFar ); + + // W result (= Z in) + m[ 3 * 4 + 0 ] = 0.0; + m[ 3 * 4 + 1 ] = 0.0; + m[ 3 * 4 + 2 ] = handednessScale; + m[ 3 * 4 + 3 ] = 0.0; + + mobj.transpose(); + return mobj; + + } + + function fovToProjection( fov, rightHanded, zNear, zFar ) { + + var DEG2RAD = Math.PI / 180.0; + + var fovPort = { + upTan: Math.tan( fov.upDegrees * DEG2RAD ), + downTan: Math.tan( fov.downDegrees * DEG2RAD ), + leftTan: Math.tan( fov.leftDegrees * DEG2RAD ), + rightTan: Math.tan( fov.rightDegrees * DEG2RAD ) + }; + + return fovPortToProjection( fovPort, rightHanded, zNear, zFar ); + + } + + }; + + /** + * @author qiao / https://github.com/qiao + * @author mrdoob / http://mrdoob.com + * @author alteredq / http://alteredqualia.com/ + * @author WestLangley / http://github.com/WestLangley + * @author erich666 / http://erichaines.com + */ + var OrbitControls = function ( object, domElement ) { + + this.object = object; + + this.domElement = ( domElement !== undefined ) ? domElement : document; + + // Set to false to disable this control + this.enabled = true; + + // "target" sets the location of focus, where the object orbits around + this.target = new Vector3(); + + // How far you can dolly in and out ( PerspectiveCamera only ) + this.minDistance = 0; + this.maxDistance = Infinity; + + // How far you can zoom in and out ( OrthographicCamera only ) + this.minZoom = 0; + this.maxZoom = Infinity; + + // How far you can orbit vertically, upper and lower limits. + // Range is 0 to Math.PI radians. + this.minPolarAngle = 0; // radians + this.maxPolarAngle = Math.PI; // radians + + // How far you can orbit horizontally, upper and lower limits. + // If set, must be a sub-interval of the interval [ - Math.PI, Math.PI ]. + this.minAzimuthAngle = - Infinity; // radians + this.maxAzimuthAngle = Infinity; // radians + + // Set to true to enable damping (inertia) + // If damping is enabled, you must call controls.update() in your animation loop + this.enableDamping = false; + this.dampingFactor = 0.25; + + // This option actually enables dollying in and out; left as "zoom" for backwards compatibility. + // Set to false to disable zooming + this.enableZoom = true; + this.zoomSpeed = 1.0; + + // Set to false to disable rotating + this.enableRotate = true; + this.rotateSpeed = 1.0; + + // Set to false to disable panning + this.enablePan = true; + this.panSpeed = 1.0; + this.screenSpacePanning = false; // if true, pan in screen-space + this.keyPanSpeed = 7.0; // pixels moved per arrow key push + + // Set to true to automatically rotate around the target + // If auto-rotate is enabled, you must call controls.update() in your animation loop + this.autoRotate = false; + this.autoRotateSpeed = 2.0; // 30 seconds per round when fps is 60 + + // Set to false to disable use of the keys + this.enableKeys = true; + + // The four arrow keys + this.keys = { LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40 }; + + // Mouse buttons + this.mouseButtons = { ORBIT: MOUSE.LEFT, ZOOM: MOUSE.MIDDLE, PAN: MOUSE.RIGHT }; + + // for reset + this.target0 = this.target.clone(); + this.position0 = this.object.position.clone(); + this.zoom0 = this.object.zoom; + + // + // public methods + // + + this.getPolarAngle = function () { + + return spherical.phi; + + }; + + this.getAzimuthalAngle = function () { + + return spherical.theta; + + }; + + this.saveState = function () { + + scope.target0.copy( scope.target ); + scope.position0.copy( scope.object.position ); + scope.zoom0 = scope.object.zoom; + + }; + + this.reset = function () { + + scope.target.copy( scope.target0 ); + scope.object.position.copy( scope.position0 ); + scope.object.zoom = scope.zoom0; + + scope.object.updateProjectionMatrix(); + scope.dispatchEvent( changeEvent ); + + scope.update(); + + state = STATE.NONE; + + }; + + // this method is exposed, but perhaps it would be better if we can make it private... + this.update = function () { + + var offset = new Vector3(); + + // so camera.up is the orbit axis + var quat = new Quaternion().setFromUnitVectors( object.up, new Vector3( 0, 1, 0 ) ); + var quatInverse = quat.clone().inverse(); + + var lastPosition = new Vector3(); + var lastQuaternion = new Quaternion(); + + return function update() { + + var position = scope.object.position; + + offset.copy( position ).sub( scope.target ); + + // rotate offset to "y-axis-is-up" space + offset.applyQuaternion( quat ); + + // angle from z-axis around y-axis + spherical.setFromVector3( offset ); + + if ( scope.autoRotate && state === STATE.NONE ) { + + scope.rotateLeft( getAutoRotationAngle() ); + + } + + spherical.theta += sphericalDelta.theta; + spherical.phi += sphericalDelta.phi; + + // restrict theta to be between desired limits + spherical.theta = Math.max( scope.minAzimuthAngle, Math.min( scope.maxAzimuthAngle, spherical.theta ) ); + + // restrict phi to be between desired limits + spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) ); + + spherical.makeSafe(); + + + spherical.radius *= scale; + + // restrict radius to be between desired limits + spherical.radius = Math.max( scope.minDistance, Math.min( scope.maxDistance, spherical.radius ) ); + + // move target to panned location + scope.target.add( panOffset ); + + offset.setFromSpherical( spherical ); + + // rotate offset back to "camera-up-vector-is-up" space + offset.applyQuaternion( quatInverse ); + + position.copy( scope.target ).add( offset ); + + scope.object.lookAt( scope.target ); + + if ( scope.enableDamping === true ) { + + sphericalDelta.theta *= ( 1 - scope.dampingFactor ); + sphericalDelta.phi *= ( 1 - scope.dampingFactor ); + + panOffset.multiplyScalar( 1 - scope.dampingFactor ); + + } else { + + sphericalDelta.set( 0, 0, 0 ); + + panOffset.set( 0, 0, 0 ); + + } + + scale = 1; + + // update condition is: + // min(camera displacement, camera rotation in radians)^2 > EPS + // using small-angle approximation cos(x/2) = 1 - x^2 / 8 + + if ( zoomChanged || + lastPosition.distanceToSquared( scope.object.position ) > EPS || + 8 * ( 1 - lastQuaternion.dot( scope.object.quaternion ) ) > EPS ) { + + scope.dispatchEvent( changeEvent ); + + lastPosition.copy( scope.object.position ); + lastQuaternion.copy( scope.object.quaternion ); + zoomChanged = false; + + return true; + + } + + return false; + + }; + + }(); + + this.dispose = function () { + + scope.domElement.removeEventListener( 'contextmenu', onContextMenu, false ); + scope.domElement.removeEventListener( 'mousedown', onMouseDown, false ); + scope.domElement.removeEventListener( 'wheel', onMouseWheel, false ); + + scope.domElement.removeEventListener( 'touchstart', onTouchStart, false ); + scope.domElement.removeEventListener( 'touchend', onTouchEnd, false ); + scope.domElement.removeEventListener( 'touchmove', onTouchMove, false ); + + document.removeEventListener( 'mousemove', onMouseMove, false ); + document.removeEventListener( 'mouseup', onMouseUp, false ); + + window.removeEventListener( 'keydown', onKeyDown, false ); + + //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? + + }; + + // + // internals + // + + var scope = this; + + var changeEvent = { type: 'change' }; + var startEvent = { type: 'start' }; + var endEvent = { type: 'end' }; + + var STATE = { NONE: - 1, ROTATE: 0, DOLLY: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_DOLLY_PAN: 4 }; + + var state = STATE.NONE; + + var EPS = 0.000001; + + // current position in spherical coordinates + var spherical = new Spherical(); + var sphericalDelta = new Spherical(); + + var scale = 1; + var panOffset = new Vector3(); + var zoomChanged = false; + + var rotateStart = new Vector2(); + var rotateEnd = new Vector2(); + var rotateDelta = new Vector2(); + + var panStart = new Vector2(); + var panEnd = new Vector2(); + var panDelta = new Vector2(); + + var dollyStart = new Vector2(); + var dollyEnd = new Vector2(); + var dollyDelta = new Vector2(); + + function getAutoRotationAngle() { + + return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed; + + } + + function getZoomScale() { + + return Math.pow( 0.95, scope.zoomSpeed ); + + } + + scope.rotateLeft = function( angle ) { + + sphericalDelta.theta -= angle; + + }; + + scope.rotateUp = function( angle ) { + + sphericalDelta.phi -= angle; + + }; + + var panLeft = function () { + + var v = new Vector3(); + + return function panLeft( distance, objectMatrix ) { + + v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix + v.multiplyScalar( - distance ); + + panOffset.add( v ); + + }; + + }(); + + var panUp = function () { + + var v = new Vector3(); + + return function panUp( distance, objectMatrix ) { + + if ( scope.screenSpacePanning === true ) { + + v.setFromMatrixColumn( objectMatrix, 1 ); + + } else { + + v.setFromMatrixColumn( objectMatrix, 0 ); + v.crossVectors( scope.object.up, v ); + + } + + v.multiplyScalar( distance ); + + panOffset.add( v ); + + }; + + }(); + + // deltaX and deltaY are in pixels; right and down are positive + var pan = function () { + + var offset = new Vector3(); + + return function pan( deltaX, deltaY ) { + + var element = scope.domElement === document ? scope.domElement.body : scope.domElement; + + if ( scope.object.isPerspectiveCamera ) { + + // perspective + var position = scope.object.position; + offset.copy( position ).sub( scope.target ); + var targetDistance = offset.length(); + + // half of the fov is center to top of screen + targetDistance *= Math.tan( ( scope.object.fov / 2 ) * Math.PI / 180.0 ); + + // we use only clientHeight here so aspect ratio does not distort speed + panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix ); + panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix ); + + } else if ( scope.object.isOrthographicCamera ) { + + // orthographic + panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix ); + panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix ); + + } else { + + // camera neither orthographic nor perspective + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' ); + scope.enablePan = false; + + } + + }; + + }(); + + function dollyIn( dollyScale ) { + + if ( scope.object.isPerspectiveCamera ) { + + scale /= dollyScale; + + } else if ( scope.object.isOrthographicCamera ) { + + scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) ); + scope.object.updateProjectionMatrix(); + zoomChanged = true; + + } else { + + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' ); + scope.enableZoom = false; + + } + + } + + function dollyOut( dollyScale ) { + + if ( scope.object.isPerspectiveCamera ) { + + scale *= dollyScale; + + } else if ( scope.object.isOrthographicCamera ) { + + scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) ); + scope.object.updateProjectionMatrix(); + zoomChanged = true; + + } else { + + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' ); + scope.enableZoom = false; + + } + + } + + // + // event callbacks - update the object state + // + + function handleMouseDownRotate( event ) { + + //console.log( 'handleMouseDownRotate' ); + + rotateStart.set( event.clientX, event.clientY ); + + } + + function handleMouseDownDolly( event ) { + + //console.log( 'handleMouseDownDolly' ); + + dollyStart.set( event.clientX, event.clientY ); + + } + + function handleMouseDownPan( event ) { + + //console.log( 'handleMouseDownPan' ); + + panStart.set( event.clientX, event.clientY ); + + } + + function handleMouseMoveRotate( event ) { + + //console.log( 'handleMouseMoveRotate' ); + + rotateEnd.set( event.clientX, event.clientY ); + + rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed ); + + var element = scope.domElement === document ? scope.domElement.body : scope.domElement; + + scope.rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height + + scope.rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight ); + + rotateStart.copy( rotateEnd ); + + scope.update(); + + } + + function handleMouseMoveDolly( event ) { + + //console.log( 'handleMouseMoveDolly' ); + + dollyEnd.set( event.clientX, event.clientY ); + + dollyDelta.subVectors( dollyEnd, dollyStart ); + + if ( dollyDelta.y > 0 ) { + + dollyIn( getZoomScale() ); + + } else if ( dollyDelta.y < 0 ) { + + dollyOut( getZoomScale() ); + + } + + dollyStart.copy( dollyEnd ); + + scope.update(); + + } + + function handleMouseMovePan( event ) { + + //console.log( 'handleMouseMovePan' ); + + panEnd.set( event.clientX, event.clientY ); + + panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed ); + + pan( panDelta.x, panDelta.y ); + + panStart.copy( panEnd ); + + scope.update(); + + } + + function handleMouseWheel( event ) { + + // console.log( 'handleMouseWheel' ); + + if ( event.deltaY < 0 ) { + + dollyOut( getZoomScale() ); + + } else if ( event.deltaY > 0 ) { + + dollyIn( getZoomScale() ); + + } + + scope.update(); + + } + + function handleKeyDown( event ) { + + //console.log( 'handleKeyDown' ); + + switch ( event.keyCode ) { + + case scope.keys.UP: + pan( 0, scope.keyPanSpeed ); + scope.update(); + break; + + case scope.keys.BOTTOM: + pan( 0, - scope.keyPanSpeed ); + scope.update(); + break; + + case scope.keys.LEFT: + pan( scope.keyPanSpeed, 0 ); + scope.update(); + break; + + case scope.keys.RIGHT: + pan( - scope.keyPanSpeed, 0 ); + scope.update(); + break; + + } + + } + + function handleTouchStartRotate( event ) { + + //console.log( 'handleTouchStartRotate' ); + + rotateStart.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); + + } + + function handleTouchStartDollyPan( event ) { + + //console.log( 'handleTouchStartDollyPan' ); + + if ( scope.enableZoom ) { + + var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX; + var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY; + + var distance = Math.sqrt( dx * dx + dy * dy ); + + dollyStart.set( 0, distance ); + + } + + if ( scope.enablePan ) { + + var x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX ); + var y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY ); + + panStart.set( x, y ); + + } + + } + + function handleTouchMoveRotate( event ) { + + //console.log( 'handleTouchMoveRotate' ); + + rotateEnd.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); + + rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed ); + + var element = scope.domElement === document ? scope.domElement.body : scope.domElement; + + scope.rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height + + scope.rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight ); + + rotateStart.copy( rotateEnd ); + + scope.update(); + + } + + function handleTouchMoveDollyPan( event ) { + + //console.log( 'handleTouchMoveDollyPan' ); + + if ( scope.enableZoom ) { + + var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX; + var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY; + + var distance = Math.sqrt( dx * dx + dy * dy ); + + dollyEnd.set( 0, distance ); + + dollyDelta.set( 0, Math.pow( dollyEnd.y / dollyStart.y, scope.zoomSpeed ) ); + + dollyIn( dollyDelta.y ); + + dollyStart.copy( dollyEnd ); + + } + + if ( scope.enablePan ) { + + var x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX ); + var y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY ); + + panEnd.set( x, y ); + + panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed ); + + pan( panDelta.x, panDelta.y ); + + panStart.copy( panEnd ); + + } + + scope.update(); + + } + + // + // event handlers - FSM: listen for events and reset state + // + + function onMouseDown( event ) { + + if ( scope.enabled === false ) return; + + event.preventDefault(); + + switch ( event.button ) { + + case scope.mouseButtons.ORBIT: + + if ( scope.enableRotate === false ) return; + + handleMouseDownRotate( event ); + + state = STATE.ROTATE; + + break; + + case scope.mouseButtons.ZOOM: + + if ( scope.enableZoom === false ) return; + + handleMouseDownDolly( event ); + + state = STATE.DOLLY; + + break; + + case scope.mouseButtons.PAN: + + if ( scope.enablePan === false ) return; + + handleMouseDownPan( event ); + + state = STATE.PAN; + + break; + + } + + if ( state !== STATE.NONE ) { + + document.addEventListener( 'mousemove', onMouseMove, false ); + document.addEventListener( 'mouseup', onMouseUp, false ); + + scope.dispatchEvent( startEvent ); + + } + + } + + function onMouseMove( event ) { + + if ( scope.enabled === false ) return; + + event.preventDefault(); + + switch ( state ) { + + case STATE.ROTATE: + + if ( scope.enableRotate === false ) return; + + handleMouseMoveRotate( event ); + + break; + + case STATE.DOLLY: + + if ( scope.enableZoom === false ) return; + + handleMouseMoveDolly( event ); + + break; + + case STATE.PAN: + + if ( scope.enablePan === false ) return; + + handleMouseMovePan( event ); + + break; + + } + + } + + function onMouseUp( event ) { + + if ( scope.enabled === false ) return; + + document.removeEventListener( 'mousemove', onMouseMove, false ); + document.removeEventListener( 'mouseup', onMouseUp, false ); + + scope.dispatchEvent( endEvent ); + + state = STATE.NONE; + + } + + function onMouseWheel( event ) { + + if ( scope.enabled === false || scope.enableZoom === false || ( state !== STATE.NONE && state !== STATE.ROTATE ) ) return; + + event.preventDefault(); + event.stopPropagation(); + + scope.dispatchEvent( startEvent ); + + handleMouseWheel( event ); + + scope.dispatchEvent( endEvent ); + + } + + function onKeyDown( event ) { + + if ( scope.enabled === false || scope.enableKeys === false || scope.enablePan === false ) return; + + handleKeyDown( event ); + + } + + function onTouchStart( event ) { + + if ( scope.enabled === false ) return; + + event.preventDefault(); + + switch ( event.touches.length ) { + + case 1: // one-fingered touch: rotate + + if ( scope.enableRotate === false ) return; + + handleTouchStartRotate( event ); + + state = STATE.TOUCH_ROTATE; + + break; + + case 2: // two-fingered touch: dolly-pan + + if ( scope.enableZoom === false && scope.enablePan === false ) return; + + handleTouchStartDollyPan( event ); + + state = STATE.TOUCH_DOLLY_PAN; + + break; + + default: + + state = STATE.NONE; + + } + + if ( state !== STATE.NONE ) { + + scope.dispatchEvent( startEvent ); + + } + + } + + function onTouchMove( event ) { + + if ( scope.enabled === false ) return; + + event.preventDefault(); + event.stopPropagation(); + + switch ( event.touches.length ) { + + case 1: // one-fingered touch: rotate + + if ( scope.enableRotate === false ) return; + if ( state !== STATE.TOUCH_ROTATE ) return; // is this needed? + + handleTouchMoveRotate( event ); + + break; + + case 2: // two-fingered touch: dolly-pan + + if ( scope.enableZoom === false && scope.enablePan === false ) return; + if ( state !== STATE.TOUCH_DOLLY_PAN ) return; // is this needed? + + handleTouchMoveDollyPan( event ); + + break; + + default: + + state = STATE.NONE; + + } + + } + + function onTouchEnd( event ) { + + if ( scope.enabled === false ) return; + + scope.dispatchEvent( endEvent ); + + state = STATE.NONE; + + } + + function onContextMenu( event ) { + + if ( scope.enabled === false ) return; + + event.preventDefault(); + + } + + // + + //scope.domElement.addEventListener\( 'contextmenu', onContextMenu, false ); + + scope.domElement.addEventListener( 'mousedown', onMouseDown, false ); + scope.domElement.addEventListener( 'wheel', onMouseWheel, false ); + + scope.domElement.addEventListener( 'touchstart', onTouchStart, false ); + scope.domElement.addEventListener( 'touchend', onTouchEnd, false ); + scope.domElement.addEventListener( 'touchmove', onTouchMove, false ); + + window.addEventListener( 'keydown', onKeyDown, false ); + + // force an update at start + + this.update(); + + }; + + OrbitControls.prototype = Object.create( EventDispatcher.prototype ); + OrbitControls.prototype.constructor = OrbitControls; + + Object.defineProperties( OrbitControls.prototype, { + + center: { + + get: function () { + + console.warn( 'OrbitControls: .center has been renamed to .target' ); + return this.target; + + } + + }, + + // backward compatibility + + noZoom: { + + get: function () { + + console.warn( 'OrbitControls: .noZoom has been deprecated. Use .enableZoom instead.' ); + return ! this.enableZoom; + + }, + + set: function ( value ) { + + console.warn( 'OrbitControls: .noZoom has been deprecated. Use .enableZoom instead.' ); + this.enableZoom = ! value; + + } + + }, + + noRotate: { + + get: function () { + + console.warn( 'OrbitControls: .noRotate has been deprecated. Use .enableRotate instead.' ); + return ! this.enableRotate; + + }, + + set: function ( value ) { + + console.warn( 'OrbitControls: .noRotate has been deprecated. Use .enableRotate instead.' ); + this.enableRotate = ! value; + + } + + }, + + noPan: { + + get: function () { + + console.warn( 'OrbitControls: .noPan has been deprecated. Use .enablePan instead.' ); + return ! this.enablePan; + + }, + + set: function ( value ) { + + console.warn( 'OrbitControls: .noPan has been deprecated. Use .enablePan instead.' ); + this.enablePan = ! value; + + } + + }, + + noKeys: { + + get: function () { + + console.warn( 'OrbitControls: .noKeys has been deprecated. Use .enableKeys instead.' ); + return ! this.enableKeys; + + }, + + set: function ( value ) { + + console.warn( 'OrbitControls: .noKeys has been deprecated. Use .enableKeys instead.' ); + this.enableKeys = ! value; + + } + + }, + + staticMoving: { + + get: function () { + + console.warn( 'OrbitControls: .staticMoving has been deprecated. Use .enableDamping instead.' ); + return ! this.enableDamping; + + }, + + set: function ( value ) { + + console.warn( 'OrbitControls: .staticMoving has been deprecated. Use .enableDamping instead.' ); + this.enableDamping = ! value; + + } + + }, + + dynamicDampingFactor: { + + get: function () { + + console.warn( 'OrbitControls: .dynamicDampingFactor has been renamed. Use .dampingFactor instead.' ); + return this.dampingFactor; + + }, + + set: function ( value ) { + + console.warn( 'OrbitControls: .dynamicDampingFactor has been renamed. Use .dampingFactor instead.' ); + this.dampingFactor = value; + + } + + } + + } ); + + /** + * @author richt / http://richt.me + * @author WestLangley / http://github.com/WestLangley + * + * W3C Device Orientation control (http://w3c.github.io/deviceorientation/spec-source-orientation.html) + */ + var DeviceOrientationControls = function ( object ) { + + var scope = this; + + this.object = object; + this.object.rotation.reorder( 'YXZ' ); + + this.enabled = true; + + this.deviceOrientation = {}; + this.screenOrientation = 0; + + this.alphaOffset = 0; // radians + + var onDeviceOrientationChangeEvent = function ( event ) { + + scope.deviceOrientation = event; + + }; + + var onScreenOrientationChangeEvent = function () { + + scope.screenOrientation = window.orientation || 0; + + }; + + // The angles alpha, beta and gamma form a set of intrinsic Tait-Bryan angles of type Z-X'-Y'' + + var setObjectQuaternion = function () { + + var zee = new Vector3( 0, 0, 1 ); + + var euler = new Euler(); + + var q0 = new Quaternion(); + + var q1 = new Quaternion( - Math.sqrt( 0.5 ), 0, 0, Math.sqrt( 0.5 ) ); // - PI/2 around the x-axis + + return function ( quaternion, alpha, beta, gamma, orient ) { + + euler.set( beta, alpha, - gamma, 'YXZ' ); // 'ZXY' for the device, but 'YXZ' for us + + quaternion.setFromEuler( euler ); // orient the device + + quaternion.multiply( q1 ); // camera looks out the back of the device, not the top + + quaternion.multiply( q0.setFromAxisAngle( zee, - orient ) ); // adjust for screen orientation + + }; + + }(); + + this.connect = function () { + + onScreenOrientationChangeEvent(); // run once on load + + window.addEventListener( 'orientationchange', onScreenOrientationChangeEvent, false ); + window.addEventListener( 'deviceorientation', onDeviceOrientationChangeEvent, false ); + + scope.enabled = true; + + }; + + this.disconnect = function () { + + window.removeEventListener( 'orientationchange', onScreenOrientationChangeEvent, false ); + window.removeEventListener( 'deviceorientation', onDeviceOrientationChangeEvent, false ); + + scope.enabled = false; + + }; + + this.update = function () { + + if ( scope.enabled === false ) return; + + var device = scope.deviceOrientation; + + if ( device ) { + + var alpha = device.alpha ? _Math.degToRad( device.alpha ) + scope.alphaOffset : 0; // Z + + var beta = device.beta ? _Math.degToRad( device.beta ) : 0; // X' + + var gamma = device.gamma ? _Math.degToRad( device.gamma ) : 0; // Y'' + + var orient = scope.screenOrientation ? _Math.degToRad( scope.screenOrientation ) : 0; // O + + setObjectQuaternion( scope.object.quaternion, alpha, beta, gamma, orient ); + + } + + + }; + + this.dispose = function () { + + scope.disconnect(); + + }; + + this.connect(); + + }; + + /** + * Convert a quaternion to an angle + * + * Taken from https://stackoverflow.com/a/35448946 + * Thanks P. Ellul + */ + + function Quat2Angle(x, y, z, w) { + var test = x * y + z * w; // singularity at north pole + + if (test > 0.499) { + var _yaw = 2 * Math.atan2(x, w); + + var _pitch = Math.PI / 2; + + var _roll = 0; + return new Vector3(_pitch, _roll, _yaw); + } // singularity at south pole + + + if (test < -0.499) { + var _yaw2 = -2 * Math.atan2(x, w); + + var _pitch2 = -Math.PI / 2; + + var _roll2 = 0; + return new Vector3(_pitch2, _roll2, _yaw2); + } + + var sqx = x * x; + var sqy = y * y; + var sqz = z * z; + var yaw = Math.atan2(2 * y * w - 2 * x * z, 1 - 2 * sqy - 2 * sqz); + var pitch = Math.asin(2 * test); + var roll = Math.atan2(2 * x * w - 2 * y * z, 1 - 2 * sqx - 2 * sqz); + return new Vector3(pitch, roll, yaw); + } + + var OrbitOrientationControls = + /*#__PURE__*/ + function () { + function OrbitOrientationControls(options) { + this.object = options.camera; + this.domElement = options.canvas; + this.orbit = new OrbitControls(this.object, this.domElement); + this.speed = 0.5; + this.orbit.target.set(0, 0, -1); + this.orbit.enableZoom = false; + this.orbit.enablePan = false; + this.orbit.rotateSpeed = -this.speed; // if orientation is supported + + if (options.orientation) { + this.orientation = new DeviceOrientationControls(this.object); + } // if projection is not full view + // limit the rotation angle in order to not display back half view + + + if (options.halfView) { + this.orbit.minAzimuthAngle = -Math.PI / 4; + this.orbit.maxAzimuthAngle = Math.PI / 4; + } + } + + var _proto = OrbitOrientationControls.prototype; + + _proto.update = function update() { + // orientation updates the camera using quaternions and + // orbit updates the camera using angles. They are incompatible + // and one update overrides the other. So before + // orbit overrides orientation we convert our quaternion changes to + // an angle change. Then save the angle into orbit so that + // it will take those into account when it updates the camera and overrides + // our changes + if (this.orientation) { + this.orientation.update(); + var quat = this.orientation.object.quaternion; + var currentAngle = Quat2Angle(quat.x, quat.y, quat.z, quat.w); // we also have to store the last angle since quaternions are b + + if (typeof this.lastAngle_ === 'undefined') { + this.lastAngle_ = currentAngle; + } + + this.orbit.rotateLeft((this.lastAngle_.z - currentAngle.z) * (1 + this.speed)); + this.orbit.rotateUp((this.lastAngle_.y - currentAngle.y) * (1 + this.speed)); + this.lastAngle_ = currentAngle; + } + + this.orbit.update(); + }; + + _proto.dispose = function dispose() { + this.orbit.dispose(); + + if (this.orientation) { + this.orientation.dispose(); + } + }; + + return OrbitOrientationControls; + }(); + + var corsSupport = function () { + var video = document$1.createElement('video'); + video.crossOrigin = 'anonymous'; + return video.hasAttribute('crossorigin'); + }(); + var validProjections = ['360', '360_LR', '360_TB', '360_CUBE', 'EAC', 'EAC_LR', 'NONE', 'AUTO', 'Sphere', 'Cube', 'equirectangular', '180']; + var getInternalProjectionName = function getInternalProjectionName(projection) { + if (!projection) { + return; + } + + projection = projection.toString().trim(); + + if (/sphere/i.test(projection)) { + return '360'; + } + + if (/cube/i.test(projection)) { + return '360_CUBE'; + } + + if (/equirectangular/i.test(projection)) { + return '360'; + } + + for (var i = 0; i < validProjections.length; i++) { + if (new RegExp('^' + validProjections[i] + '$', 'i').test(projection)) { + return validProjections[i]; + } + } + }; + + /** + * This class reacts to interactions with the canvas and + * triggers appropriate functionality on the player. Right now + * it does two things: + * + * 1. A `mousedown`/`touchstart` followed by `touchend`/`mouseup` without any + * `touchmove` or `mousemove` toggles play/pause on the player + * 2. Only moving on/clicking the control bar or toggling play/pause should + * show the control bar. Moving around the scene in the canvas should not. + */ + + var CanvasPlayerControls = + /*#__PURE__*/ + function (_videojs$EventTarget) { + inheritsLoose(CanvasPlayerControls, _videojs$EventTarget); + + function CanvasPlayerControls(player, canvas) { + var _this; + + _this = _videojs$EventTarget.call(this) || this; + _this.player = player; + _this.canvas = canvas; + _this.onMoveEnd = videojs.bind(assertThisInitialized(_this), _this.onMoveEnd); + _this.onMoveStart = videojs.bind(assertThisInitialized(_this), _this.onMoveStart); + _this.onMove = videojs.bind(assertThisInitialized(_this), _this.onMove); + _this.onControlBarMove = videojs.bind(assertThisInitialized(_this), _this.onControlBarMove); + + _this.player.controlBar.on(['mousedown', 'mousemove', 'mouseup', 'touchstart', 'touchmove', 'touchend'], _this.onControlBarMove); // we have to override these here because + // video.js listens for user activity on the video element + // and makes the user active when the mouse moves. + // We don't want that for 3d videos + + + _this.oldReportUserActivity = _this.player.reportUserActivity; + + _this.player.reportUserActivity = function () {}; // canvas movements + + + _this.canvas.addEventListener('mousedown', _this.onMoveStart); + + _this.canvas.addEventListener('touchstart', _this.onMoveStart); + + _this.canvas.addEventListener('mousemove', _this.onMove); + + _this.canvas.addEventListener('touchmove', _this.onMove); + + _this.canvas.addEventListener('mouseup', _this.onMoveEnd); + + _this.canvas.addEventListener('touchend', _this.onMoveEnd); + + _this.shouldTogglePlay = false; + return _this; + } + + var _proto = CanvasPlayerControls.prototype; + + _proto.togglePlay = function togglePlay() { + if (this.player.paused()) { + this.player.play(); + } else { + this.player.pause(); + } + }; + + _proto.onMoveStart = function onMoveStart(e) { + // if the player does not have a controlbar or + // the move was a mouse click but not left click do not + // toggle play. + if (!this.player.controls() || e.type === 'mousedown' && !videojs.dom.isSingleLeftClick(e)) { + this.shouldTogglePlay = false; + return; + } + + this.shouldTogglePlay = true; + this.touchMoveCount_ = 0; + }; + + _proto.onMoveEnd = function onMoveEnd(e) { + // We want to have the same behavior in VR360 Player and standar player. + // in touchend we want to know if was a touch click, for a click we show the bar, + // otherwise continue with the mouse logic. + // + // Maximum movement allowed during a touch event to still be considered a tap + // Other popular libs use anywhere from 2 (hammer.js) to 15, + // so 10 seems like a nice, round number. + if (e.type === 'touchend' && this.touchMoveCount_ < 10) { + if (this.player.userActive() === false) { + this.player.userActive(true); + return; + } + + this.player.userActive(false); + return; + } + + if (!this.shouldTogglePlay) { + return; + } // We want the same behavior in Desktop for VR360 and standar player + + + if (e.type == 'mouseup') { + this.togglePlay(); + } + }; + + _proto.onMove = function onMove(e) { + // Increase touchMoveCount_ since Android detects 1 - 6 touches when user click normaly + this.touchMoveCount_++; + this.shouldTogglePlay = false; + }; + + _proto.onControlBarMove = function onControlBarMove(e) { + this.player.userActive(true); + }; + + _proto.dispose = function dispose() { + this.canvas.removeEventListener('mousedown', this.onMoveStart); + this.canvas.removeEventListener('touchstart', this.onMoveStart); + this.canvas.removeEventListener('mousemove', this.onMove); + this.canvas.removeEventListener('touchmove', this.onMove); + this.canvas.removeEventListener('mouseup', this.onMoveEnd); + this.canvas.removeEventListener('touchend', this.onMoveEnd); + this.player.controlBar.off(['mousedown', 'mousemove', 'mouseup', 'touchstart', 'touchmove', 'touchend'], this.onControlBarMove); + this.player.reportUserActivity = this.oldReportUserActivity; + }; + + return CanvasPlayerControls; + }(videojs.EventTarget); + + /** + * This class manages ambisonic decoding and binaural rendering via Omnitone library. + */ + + var OmnitoneController = + /*#__PURE__*/ + function (_videojs$EventTarget) { + inheritsLoose(OmnitoneController, _videojs$EventTarget); + + /** + * Omnitone controller class. + * + * @class + * @param {AudioContext} audioContext - associated AudioContext. + * @param {Omnitone library} omnitone - Omnitone library element. + * @param {HTMLVideoElement} video - vidoe tag element. + * @param {Object} options - omnitone options. + */ + function OmnitoneController(audioContext, omnitone, video, options) { + var _this; + + _this = _videojs$EventTarget.call(this) || this; + var settings = videojs.mergeOptions({ + // Safari uses the different AAC decoder than FFMPEG. The channel order is + // The default 4ch AAC channel layout for FFMPEG AAC channel ordering. + channelMap: videojs.browser.IS_SAFARI ? [2, 0, 1, 3] : [0, 1, 2, 3], + ambisonicOrder: 1 + }, options); + _this.videoElementSource = audioContext.createMediaElementSource(video); + _this.foaRenderer = omnitone.createFOARenderer(audioContext, settings); + + _this.foaRenderer.initialize().then(function () { + if (audioContext.state === 'suspended') { + _this.trigger({ + type: 'audiocontext-suspended' + }); + } + + _this.videoElementSource.connect(_this.foaRenderer.input); + + _this.foaRenderer.output.connect(audioContext.destination); + + _this.initialized = true; + + _this.trigger({ + type: 'omnitone-ready' + }); + }, function (error) { + videojs.log.warn("videojs-vr: Omnitone initializes failed with the following error: " + error + ")"); + }); + + return _this; + } + /** + * Updates the rotation of the Omnitone decoder based on three.js camera matrix. + * + * @param {Camera} camera Three.js camera object + */ + + + var _proto = OmnitoneController.prototype; + + _proto.update = function update(camera) { + if (!this.initialized) { + return; + } + + this.foaRenderer.setRotationMatrixFromCamera(camera.matrix); + } + /** + * Destroys the controller and does any necessary cleanup. + */ + ; + + _proto.dispose = function dispose() { + this.initialized = false; + this.foaRenderer.setRenderingMode('bypass'); + this.foaRenderer = null; + }; + + return OmnitoneController; + }(videojs.EventTarget); + + var Button = videojs.getComponent('Button'); + + var CardboardButton = + /*#__PURE__*/ + function (_Button) { + inheritsLoose(CardboardButton, _Button); + + function CardboardButton(player, options) { + var _this; + + _this = _Button.call(this, player, options) || this; + _this.handleVrDisplayActivate_ = videojs.bind(assertThisInitialized(_this), _this.handleVrDisplayActivate_); + _this.handleVrDisplayDeactivate_ = videojs.bind(assertThisInitialized(_this), _this.handleVrDisplayDeactivate_); + _this.handleVrDisplayPresentChange_ = videojs.bind(assertThisInitialized(_this), _this.handleVrDisplayPresentChange_); + _this.handleOrientationChange_ = videojs.bind(assertThisInitialized(_this), _this.handleOrientationChange_); + window$1.addEventListener('orientationchange', _this.handleOrientationChange_); + window$1.addEventListener('vrdisplayactivate', _this.handleVrDisplayActivate_); + window$1.addEventListener('vrdisplaydeactivate', _this.handleVrDisplayDeactivate_); // vrdisplaypresentchange does not fire activate or deactivate + // and happens when hitting the back button during cardboard mode + // so we need to make sure we stay in the correct state by + // listening to it and checking if we are presenting it or not + + window$1.addEventListener('vrdisplaypresentchange', _this.handleVrDisplayPresentChange_); // we cannot show the cardboard button in fullscreen on + // android as it breaks the controls, and makes it impossible + // to exit cardboard mode + + if (videojs.browser.IS_ANDROID) { + _this.on(player, 'fullscreenchange', function () { + if (player.isFullscreen()) { + _this.hide(); + } else { + _this.show(); + } + }); + } + + return _this; + } + + var _proto = CardboardButton.prototype; + + _proto.buildCSSClass = function buildCSSClass() { + return "vjs-button-vr " + _Button.prototype.buildCSSClass.call(this); + }; + + _proto.handleVrDisplayPresentChange_ = function handleVrDisplayPresentChange_() { + if (!this.player_.vr().vrDisplay.isPresenting && this.active_) { + this.handleVrDisplayDeactivate_(); + } + + if (this.player_.vr().vrDisplay.isPresenting && !this.active_) { + this.handleVrDisplayActivate_(); + } + }; + + _proto.handleOrientationChange_ = function handleOrientationChange_() { + if (this.active_ && videojs.browser.IS_IOS) { + this.changeSize_(); + } + }; + + _proto.changeSize_ = function changeSize_() { + this.player_.width(window$1.innerWidth); + this.player_.height(window$1.innerHeight); + window$1.dispatchEvent(new window$1.Event('resize')); + }; + + _proto.handleVrDisplayActivate_ = function handleVrDisplayActivate_() { + // we mimic fullscreen on IOS + if (videojs.browser.IS_IOS) { + this.oldWidth_ = this.player_.currentWidth(); + this.oldHeight_ = this.player_.currentHeight(); + this.player_.enterFullWindow(); + this.changeSize_(); + } + + this.active_ = true; + }; + + _proto.handleVrDisplayDeactivate_ = function handleVrDisplayDeactivate_() { + // un-mimic fullscreen on iOS + if (videojs.browser.IS_IOS) { + if (this.oldWidth_) { + this.player_.width(this.oldWidth_); + } + + if (this.oldHeight_) { + this.player_.height(this.oldHeight_); + } + + this.player_.exitFullWindow(); + } + + this.active_ = false; + }; + + _proto.handleClick = function handleClick(event) { + // if cardboard mode display is not active, activate it + // otherwise deactivate it + if (!this.active_) { + // This starts playback mode when the cardboard button + // is clicked on Andriod. We need to do this as the controls + // disappear + if (!this.player_.hasStarted() && videojs.browser.IS_ANDROID) { + this.player_.play(); + } + + window$1.dispatchEvent(new window$1.Event('vrdisplayactivate')); + } else { + window$1.dispatchEvent(new window$1.Event('vrdisplaydeactivate')); + } + }; + + _proto.dispose = function dispose() { + _Button.prototype.dispose.call(this); + + window$1.removeEventListener('vrdisplayactivate', this.handleVrDisplayActivate_); + window$1.removeEventListener('vrdisplaydeactivate', this.handleVrDisplayDeactivate_); + window$1.removeEventListener('vrdisplaypresentchange', this.handleVrDisplayPresentChange_); + }; + + return CardboardButton; + }(Button); + + videojs.registerComponent('CardboardButton', CardboardButton); + + var BigPlayButton = videojs.getComponent('BigPlayButton'); + + var BigVrPlayButton = + /*#__PURE__*/ + function (_BigPlayButton) { + inheritsLoose(BigVrPlayButton, _BigPlayButton); + + function BigVrPlayButton() { + return _BigPlayButton.apply(this, arguments) || this; + } + + var _proto = BigVrPlayButton.prototype; + + _proto.buildCSSClass = function buildCSSClass() { + return "vjs-big-vr-play-button " + _BigPlayButton.prototype.buildCSSClass.call(this); + }; + + return BigVrPlayButton; + }(BigPlayButton); + + videojs.registerComponent('BigVrPlayButton', BigVrPlayButton); + + var defaults = { + debug: false, + omnitone: false, + forceCardboard: false, + omnitoneOptions: {}, + projection: 'AUTO' + }; + var errors = { + 'web-vr-out-of-date': { + headline: '360 is out of date', + type: '360_OUT_OF_DATE', + message: "Your browser supports 360 but not the latest version. See http://webvr.info for more info." + }, + 'web-vr-not-supported': { + headline: '360 not supported on this device', + type: '360_NOT_SUPPORTED', + message: "Your browser does not support 360. See http://webvr.info for assistance." + }, + 'web-vr-hls-cors-not-supported': { + headline: '360 HLS video not supported on this device', + type: '360_NOT_SUPPORTED', + message: "Your browser/device does not support HLS 360 video. See http://webvr.info for assistance." + } + }; + var Plugin = videojs.getPlugin('plugin'); + var Component = videojs.getComponent('Component'); + + var VR = + /*#__PURE__*/ + function (_Plugin) { + inheritsLoose(VR, _Plugin); + + function VR(player, options) { + var _this; + + var settings = videojs.mergeOptions(defaults, options); + _this = _Plugin.call(this, player, settings) || this; + _this.options_ = settings; + _this.player_ = player; + _this.bigPlayButtonIndex_ = player.children().indexOf(player.getChild('BigPlayButton')) || 0; // custom videojs-errors integration boolean + + _this.videojsErrorsSupport_ = !!videojs.errors; + + if (_this.videojsErrorsSupport_) { + player.errors({ + errors: errors + }); + } // IE 11 does not support enough webgl to be supported + // older safari does not support cors, so it wont work + + + if (videojs.browser.IE_VERSION || !corsSupport) { + // if a player triggers error before 'loadstart' is fired + // video.js will reset the error overlay + _this.player_.on('loadstart', function () { + _this.triggerError_({ + code: 'web-vr-not-supported', + dismiss: false + }); + }); + + return assertThisInitialized(_this); + } + + _this.polyfill_ = new WebVRPolyfill({ + // do not show rotate instructions + ROTATE_INSTRUCTIONS_DISABLED: true + }); + _this.polyfill_ = new WebVRPolyfill(); + _this.handleVrDisplayActivate_ = videojs.bind(assertThisInitialized(_this), _this.handleVrDisplayActivate_); + _this.handleVrDisplayDeactivate_ = videojs.bind(assertThisInitialized(_this), _this.handleVrDisplayDeactivate_); + _this.handleResize_ = videojs.bind(assertThisInitialized(_this), _this.handleResize_); + _this.animate_ = videojs.bind(assertThisInitialized(_this), _this.animate_); + + _this.setProjection(_this.options_.projection); // any time the video element is recycled for ads + // we have to reset the vr state and re-init after ad + + + _this.on(player, 'adstart', function () { + return player.setTimeout(function () { + // if the video element was recycled for this ad + if (!player.ads || !player.ads.videoElementRecycled()) { + _this.log('video element not recycled for this ad, no need to reset'); + + return; + } + + _this.log('video element recycled for this ad, reseting'); + + _this.reset(); + + _this.one(player, 'playing', _this.init); + }); + }, 1); + + _this.on(player, 'loadedmetadata', _this.init); + + return _this; + } + + var _proto = VR.prototype; + + _proto.changeProjection_ = function changeProjection_(projection) { + var _this2 = this; + + projection = getInternalProjectionName(projection); // don't change to an invalid projection + + if (!projection) { + projection = 'NONE'; + } + + var position = { + x: 0, + y: 0, + z: 0 + }; + + if (this.scene) { + this.scene.remove(this.movieScreen); + } + + if (projection === 'AUTO') { + // mediainfo cannot be set to auto or we would infinite loop here + // each source should know wether they are 360 or not, if using AUTO + if (this.player_.mediainfo && this.player_.mediainfo.projection && this.player_.mediainfo.projection !== 'AUTO') { + var autoProjection = getInternalProjectionName(this.player_.mediainfo.projection); + return this.changeProjection_(autoProjection); + } + + return this.changeProjection_('NONE'); + } else if (projection === '360') { + this.movieGeometry = new SphereBufferGeometry(256, 32, 32); + this.movieMaterial = new MeshBasicMaterial({ + map: this.videoTexture, + overdraw: true, + side: BackSide + }); + this.movieScreen = new Mesh(this.movieGeometry, this.movieMaterial); + this.movieScreen.position.set(position.x, position.y, position.z); + this.movieScreen.scale.x = -1; + this.movieScreen.quaternion.setFromAxisAngle({ + x: 0, + y: 1, + z: 0 + }, -Math.PI / 2); + this.scene.add(this.movieScreen); + } else if (projection === '360_LR' || projection === '360_TB') { + // Left eye view + var geometry = new SphereGeometry(256, 32, 32); + var uvs = geometry.faceVertexUvs[0]; + + for (var i = 0; i < uvs.length; i++) { + for (var j = 0; j < 3; j++) { + if (projection === '360_LR') { + uvs[i][j].x *= 0.5; + } else { + uvs[i][j].y *= 0.5; + uvs[i][j].y += 0.5; + } + } + } + + this.movieGeometry = new BufferGeometry().fromGeometry(geometry); + this.movieMaterial = new MeshBasicMaterial({ + map: this.videoTexture, + overdraw: true, + side: BackSide + }); + this.movieScreen = new Mesh(this.movieGeometry, this.movieMaterial); + this.movieScreen.scale.x = -1; + this.movieScreen.quaternion.setFromAxisAngle({ + x: 0, + y: 1, + z: 0 + }, -Math.PI / 2); // display in left eye only + + this.movieScreen.layers.set(1); + this.scene.add(this.movieScreen); // Right eye view + + geometry = new SphereGeometry(256, 32, 32); + uvs = geometry.faceVertexUvs[0]; + + for (var _i = 0; _i < uvs.length; _i++) { + for (var _j = 0; _j < 3; _j++) { + if (projection === '360_LR') { + uvs[_i][_j].x *= 0.5; + uvs[_i][_j].x += 0.5; + } else { + uvs[_i][_j].y *= 0.5; + } + } + } + + this.movieGeometry = new BufferGeometry().fromGeometry(geometry); + this.movieMaterial = new MeshBasicMaterial({ + map: this.videoTexture, + overdraw: true, + side: BackSide + }); + this.movieScreen = new Mesh(this.movieGeometry, this.movieMaterial); + this.movieScreen.scale.x = -1; + this.movieScreen.quaternion.setFromAxisAngle({ + x: 0, + y: 1, + z: 0 + }, -Math.PI / 2); // display in right eye only + + this.movieScreen.layers.set(2); + this.scene.add(this.movieScreen); + } else if (projection === '360_CUBE') { + this.movieGeometry = new BoxGeometry(256, 256, 256); + this.movieMaterial = new MeshBasicMaterial({ + map: this.videoTexture, + overdraw: true, + side: BackSide + }); + var left = [new Vector2(0, 0.5), new Vector2(0.333, 0.5), new Vector2(0.333, 1), new Vector2(0, 1)]; + var right = [new Vector2(0.333, 0.5), new Vector2(0.666, 0.5), new Vector2(0.666, 1), new Vector2(0.333, 1)]; + var top = [new Vector2(0.666, 0.5), new Vector2(1, 0.5), new Vector2(1, 1), new Vector2(0.666, 1)]; + var bottom = [new Vector2(0, 0), new Vector2(0.333, 0), new Vector2(0.333, 0.5), new Vector2(0, 0.5)]; + var front = [new Vector2(0.333, 0), new Vector2(0.666, 0), new Vector2(0.666, 0.5), new Vector2(0.333, 0.5)]; + var back = [new Vector2(0.666, 0), new Vector2(1, 0), new Vector2(1, 0.5), new Vector2(0.666, 0.5)]; + this.movieGeometry.faceVertexUvs[0] = []; + this.movieGeometry.faceVertexUvs[0][0] = [right[2], right[1], right[3]]; + this.movieGeometry.faceVertexUvs[0][1] = [right[1], right[0], right[3]]; + this.movieGeometry.faceVertexUvs[0][2] = [left[2], left[1], left[3]]; + this.movieGeometry.faceVertexUvs[0][3] = [left[1], left[0], left[3]]; + this.movieGeometry.faceVertexUvs[0][4] = [top[2], top[1], top[3]]; + this.movieGeometry.faceVertexUvs[0][5] = [top[1], top[0], top[3]]; + this.movieGeometry.faceVertexUvs[0][6] = [bottom[2], bottom[1], bottom[3]]; + this.movieGeometry.faceVertexUvs[0][7] = [bottom[1], bottom[0], bottom[3]]; + this.movieGeometry.faceVertexUvs[0][8] = [front[2], front[1], front[3]]; + this.movieGeometry.faceVertexUvs[0][9] = [front[1], front[0], front[3]]; + this.movieGeometry.faceVertexUvs[0][10] = [back[2], back[1], back[3]]; + this.movieGeometry.faceVertexUvs[0][11] = [back[1], back[0], back[3]]; + this.movieScreen = new Mesh(this.movieGeometry, this.movieMaterial); + this.movieScreen.position.set(position.x, position.y, position.z); + this.movieScreen.rotation.y = -Math.PI; + this.scene.add(this.movieScreen); + } else if (projection === '180') { + var _geometry = new SphereGeometry(256, 32, 32, Math.PI, Math.PI); // Left eye view + + + _geometry.scale(-1, 1, 1); + + var _uvs = _geometry.faceVertexUvs[0]; + + for (var _i2 = 0; _i2 < _uvs.length; _i2++) { + for (var _j2 = 0; _j2 < 3; _j2++) { + _uvs[_i2][_j2].x *= 0.5; + } + } + + this.movieGeometry = new BufferGeometry().fromGeometry(_geometry); + this.movieMaterial = new MeshBasicMaterial({ + map: this.videoTexture, + overdraw: true + }); + this.movieScreen = new Mesh(this.movieGeometry, this.movieMaterial); // display in left eye only + + this.movieScreen.layers.set(1); + this.scene.add(this.movieScreen); // Right eye view + + _geometry = new SphereGeometry(256, 32, 32, Math.PI, Math.PI); + + _geometry.scale(-1, 1, 1); + + _uvs = _geometry.faceVertexUvs[0]; + + for (var _i3 = 0; _i3 < _uvs.length; _i3++) { + for (var _j3 = 0; _j3 < 3; _j3++) { + _uvs[_i3][_j3].x *= 0.5; + _uvs[_i3][_j3].x += 0.5; + } + } + + this.movieGeometry = new BufferGeometry().fromGeometry(_geometry); + this.movieMaterial = new MeshBasicMaterial({ + map: this.videoTexture, + overdraw: true + }); + this.movieScreen = new Mesh(this.movieGeometry, this.movieMaterial); // display in right eye only + + this.movieScreen.layers.set(2); + this.scene.add(this.movieScreen); + } else if (projection === 'EAC' || projection === 'EAC_LR') { + var makeScreen = function makeScreen(mapMatrix, scaleMatrix) { + // "Continuity correction?": because of discontinuous faces and aliasing, + // we truncate the 2-pixel-wide strips on all discontinuous edges, + var contCorrect = 2; + _this2.movieGeometry = new BoxGeometry(256, 256, 256); + _this2.movieMaterial = new ShaderMaterial({ + overdraw: true, + side: BackSide, + uniforms: { + mapped: { + value: _this2.videoTexture + }, + mapMatrix: { + value: mapMatrix + }, + contCorrect: { + value: contCorrect + }, + faceWH: { + value: new Vector2(1 / 3, 1 / 2).applyMatrix3(scaleMatrix) + }, + vidWH: { + value: new Vector2(_this2.videoTexture.image.videoWidth, _this2.videoTexture.image.videoHeight).applyMatrix3(scaleMatrix) + } + }, + vertexShader: "\nvarying vec2 vUv;\nuniform mat3 mapMatrix;\n\nvoid main() {\n vUv = (mapMatrix * vec3(uv, 1.)).xy;\n gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.);\n}", + fragmentShader: "\nvarying vec2 vUv;\nuniform sampler2D mapped;\nuniform vec2 faceWH;\nuniform vec2 vidWH;\nuniform float contCorrect;\n\nconst float PI = 3.1415926535897932384626433832795;\n\nvoid main() {\n vec2 corner = vUv - mod(vUv, faceWH) + vec2(0, contCorrect / vidWH.y);\n\n vec2 faceWHadj = faceWH - vec2(0, contCorrect * 2. / vidWH.y);\n\n vec2 p = (vUv - corner) / faceWHadj - .5;\n vec2 q = 2. / PI * atan(2. * p) + .5;\n\n vec2 eUv = corner + q * faceWHadj;\n\n gl_FragColor = texture2D(mapped, eUv);\n}" + }); + var right = [new Vector2(0, 1 / 2), new Vector2(1 / 3, 1 / 2), new Vector2(1 / 3, 1), new Vector2(0, 1)]; + var front = [new Vector2(1 / 3, 1 / 2), new Vector2(2 / 3, 1 / 2), new Vector2(2 / 3, 1), new Vector2(1 / 3, 1)]; + var left = [new Vector2(2 / 3, 1 / 2), new Vector2(1, 1 / 2), new Vector2(1, 1), new Vector2(2 / 3, 1)]; + var bottom = [new Vector2(1 / 3, 0), new Vector2(1 / 3, 1 / 2), new Vector2(0, 1 / 2), new Vector2(0, 0)]; + var back = [new Vector2(1 / 3, 1 / 2), new Vector2(1 / 3, 0), new Vector2(2 / 3, 0), new Vector2(2 / 3, 1 / 2)]; + var top = [new Vector2(1, 0), new Vector2(1, 1 / 2), new Vector2(2 / 3, 1 / 2), new Vector2(2 / 3, 0)]; + + for (var _i4 = 0, _arr = [right, front, left, bottom, back, top]; _i4 < _arr.length; _i4++) { + var face = _arr[_i4]; + var height = _this2.videoTexture.image.videoHeight; + var lowY = 1; + var highY = 0; + + for (var _iterator = face, _isArray = Array.isArray(_iterator), _i5 = 0, _iterator = _isArray ? _iterator : _iterator[Symbol.iterator]();;) { + var _ref; + + if (_isArray) { + if (_i5 >= _iterator.length) break; + _ref = _iterator[_i5++]; + } else { + _i5 = _iterator.next(); + if (_i5.done) break; + _ref = _i5.value; + } + + var vector = _ref; + + if (vector.y < lowY) { + lowY = vector.y; + } + + if (vector.y > highY) { + highY = vector.y; + } + } + + for (var _iterator2 = face, _isArray2 = Array.isArray(_iterator2), _i6 = 0, _iterator2 = _isArray2 ? _iterator2 : _iterator2[Symbol.iterator]();;) { + var _ref2; + + if (_isArray2) { + if (_i6 >= _iterator2.length) break; + _ref2 = _iterator2[_i6++]; + } else { + _i6 = _iterator2.next(); + if (_i6.done) break; + _ref2 = _i6.value; + } + + var _vector = _ref2; + + if (Math.abs(_vector.y - lowY) < Number.EPSILON) { + _vector.y += contCorrect / height; + } + + if (Math.abs(_vector.y - highY) < Number.EPSILON) { + _vector.y -= contCorrect / height; + } + + _vector.x = _vector.x / height * (height - contCorrect * 2) + contCorrect / height; + } + } + + _this2.movieGeometry.faceVertexUvs[0] = []; + _this2.movieGeometry.faceVertexUvs[0][0] = [right[2], right[1], right[3]]; + _this2.movieGeometry.faceVertexUvs[0][1] = [right[1], right[0], right[3]]; + _this2.movieGeometry.faceVertexUvs[0][2] = [left[2], left[1], left[3]]; + _this2.movieGeometry.faceVertexUvs[0][3] = [left[1], left[0], left[3]]; + _this2.movieGeometry.faceVertexUvs[0][4] = [top[2], top[1], top[3]]; + _this2.movieGeometry.faceVertexUvs[0][5] = [top[1], top[0], top[3]]; + _this2.movieGeometry.faceVertexUvs[0][6] = [bottom[2], bottom[1], bottom[3]]; + _this2.movieGeometry.faceVertexUvs[0][7] = [bottom[1], bottom[0], bottom[3]]; + _this2.movieGeometry.faceVertexUvs[0][8] = [front[2], front[1], front[3]]; + _this2.movieGeometry.faceVertexUvs[0][9] = [front[1], front[0], front[3]]; + _this2.movieGeometry.faceVertexUvs[0][10] = [back[2], back[1], back[3]]; + _this2.movieGeometry.faceVertexUvs[0][11] = [back[1], back[0], back[3]]; + _this2.movieScreen = new Mesh(_this2.movieGeometry, _this2.movieMaterial); + + _this2.movieScreen.position.set(position.x, position.y, position.z); + + _this2.movieScreen.rotation.y = -Math.PI; + return _this2.movieScreen; + }; + + if (projection === 'EAC') { + this.scene.add(makeScreen(new Matrix3(), new Matrix3())); + } else { + var scaleMatrix = new Matrix3().set(0, 0.5, 0, 1, 0, 0, 0, 0, 1); + makeScreen(new Matrix3().set(0, -0.5, 0.5, 1, 0, 0, 0, 0, 1), scaleMatrix); // display in left eye only + + this.movieScreen.layers.set(1); + this.scene.add(this.movieScreen); + makeScreen(new Matrix3().set(0, -0.5, 1, 1, 0, 0, 0, 0, 1), scaleMatrix); // display in right eye only + + this.movieScreen.layers.set(2); + this.scene.add(this.movieScreen); + } + } + + this.currentProjection_ = projection; + }; + + _proto.triggerError_ = function triggerError_(errorObj) { + // if we have videojs-errors use it + if (this.videojsErrorsSupport_) { + this.player_.error(errorObj); // if we don't have videojs-errors just use a normal player error + } else { + // strip any html content from the error message + // as it is not supported outside of videojs-errors + var div = document$1.createElement('div'); + div.innerHTML = errors[errorObj.code].message; + var message = div.textContent || div.innerText || ''; + this.player_.error({ + code: errorObj.code, + message: message + }); + } + }; + + _proto.log = function log() { + if (!this.options_.debug) { + return; + } + + for (var _len = arguments.length, msgs = new Array(_len), _key = 0; _key < _len; _key++) { + msgs[_key] = arguments[_key]; + } + + msgs.forEach(function (msg) { + videojs.log('VR: ', msg); + }); + }; + + _proto.handleVrDisplayActivate_ = function handleVrDisplayActivate_() { + var _this3 = this; + + if (!this.vrDisplay) { + return; + } + + this.vrDisplay.requestPresent([{ + source: this.renderedCanvas + }]).then(function () { + if (!_this3.vrDisplay.cardboardUI_ || !videojs.browser.IS_IOS) { + return; + } // webvr-polyfill/cardboard ui only watches for click events + // to tell that the back arrow button is pressed during cardboard vr. + // but somewhere along the line these events are silenced with preventDefault + // but only on iOS, so we translate them ourselves here + + + var touches = []; + + var iosCardboardTouchStart_ = function iosCardboardTouchStart_(e) { + for (var i = 0; i < e.touches.length; i++) { + touches.push(e.touches[i]); + } + }; + + var iosCardboardTouchEnd_ = function iosCardboardTouchEnd_(e) { + if (!touches.length) { + return; + } + + touches.forEach(function (t) { + var simulatedClick = new window$1.MouseEvent('click', { + screenX: t.screenX, + screenY: t.screenY, + clientX: t.clientX, + clientY: t.clientY + }); + + _this3.renderedCanvas.dispatchEvent(simulatedClick); + }); + touches = []; + }; + + _this3.renderedCanvas.addEventListener('touchstart', iosCardboardTouchStart_); + + _this3.renderedCanvas.addEventListener('touchend', iosCardboardTouchEnd_); + + _this3.iosRevertTouchToClick_ = function () { + _this3.renderedCanvas.removeEventListener('touchstart', iosCardboardTouchStart_); + + _this3.renderedCanvas.removeEventListener('touchend', iosCardboardTouchEnd_); + + _this3.iosRevertTouchToClick_ = null; + }; + }); + }; + + _proto.handleVrDisplayDeactivate_ = function handleVrDisplayDeactivate_() { + if (!this.vrDisplay || !this.vrDisplay.isPresenting) { + return; + } + + if (this.iosRevertTouchToClick_) { + this.iosRevertTouchToClick_(); + } + + this.vrDisplay.exitPresent(); + }; + + _proto.requestAnimationFrame = function requestAnimationFrame(fn) { + if (this.vrDisplay) { + return this.vrDisplay.requestAnimationFrame(fn); + } + + return this.player_.requestAnimationFrame(fn); + }; + + _proto.cancelAnimationFrame = function cancelAnimationFrame(id) { + if (this.vrDisplay) { + return this.vrDisplay.cancelAnimationFrame(id); + } + + return this.player_.cancelAnimationFrame(id); + }; + + _proto.togglePlay_ = function togglePlay_() { + if (this.player_.paused()) { + this.player_.play(); + } else { + this.player_.pause(); + } + }; + + _proto.animate_ = function animate_() { + if (!this.initialized_) { + return; + } + + if (this.getVideoEl_().readyState === this.getVideoEl_().HAVE_ENOUGH_DATA) { + if (this.videoTexture) { + this.videoTexture.needsUpdate = true; + } + } + + this.controls3d.update(); + + if (this.omniController) { + this.omniController.update(this.camera); + } + + this.effect.render(this.scene, this.camera); + + if (window$1.navigator.getGamepads) { + // Grab all gamepads + var gamepads = window$1.navigator.getGamepads(); + + for (var i = 0; i < gamepads.length; ++i) { + var gamepad = gamepads[i]; // Make sure gamepad is defined + // Only take input if state has changed since we checked last + + if (!gamepad || !gamepad.timestamp || gamepad.timestamp === this.prevTimestamps_[i]) { + continue; + } + + for (var j = 0; j < gamepad.buttons.length; ++j) { + if (gamepad.buttons[j].pressed) { + this.togglePlay_(); + this.prevTimestamps_[i] = gamepad.timestamp; + break; + } + } + } + } + + this.camera.getWorldDirection(this.cameraVector); + this.animationFrameId_ = this.requestAnimationFrame(this.animate_); + }; + + _proto.handleResize_ = function handleResize_() { + var width = this.player_.currentWidth(); + var height = this.player_.currentHeight(); + this.effect.setSize(width, height, false); + this.camera.aspect = width / height; + this.camera.updateProjectionMatrix(); + }; + + _proto.setProjection = function setProjection(projection) { + if (!getInternalProjectionName(projection)) { + videojs.log.error('videojs-vr: please pass a valid projection ' + validProjections.join(', ')); + return; + } + + this.currentProjection_ = projection; + this.defaultProjection_ = projection; + }; + + _proto.init = function init() { + var _this4 = this; + + this.reset(); + this.camera = new PerspectiveCamera(75, this.player_.currentWidth() / this.player_.currentHeight(), 1, 1000); // Store vector representing the direction in which the camera is looking, in world space. + + this.cameraVector = new Vector3(); + + if (this.currentProjection_ === '360_LR' || this.currentProjection_ === '360_TB' || this.currentProjection_ === '180' || this.currentProjection_ === 'EAC_LR') { + // Render left eye when not in VR mode + this.camera.layers.enable(1); + } + + this.scene = new Scene(); + this.videoTexture = new VideoTexture(this.getVideoEl_()); // shared regardless of wether VideoTexture is used or + // an image canvas is used + + this.videoTexture.generateMipmaps = false; + this.videoTexture.minFilter = LinearFilter; + this.videoTexture.magFilter = LinearFilter; + this.videoTexture.format = RGBFormat; + this.changeProjection_(this.currentProjection_); + + if (this.currentProjection_ === 'NONE') { + this.log('Projection is NONE, dont init'); + this.reset(); + return; + } + + this.player_.removeChild('BigPlayButton'); + this.player_.addChild('BigVrPlayButton', {}, this.bigPlayButtonIndex_); + this.player_.bigPlayButton = this.player_.getChild('BigVrPlayButton'); // mobile devices, or cardboard forced to on + + if (this.options_.forceCardboard || videojs.browser.IS_ANDROID || videojs.browser.IS_IOS) { + this.addCardboardButton_(); + } // if ios remove full screen toggle + + + if (videojs.browser.IS_IOS) { + this.player_.controlBar.fullscreenToggle.hide(); + } + + this.camera.position.set(0, 0, 0); + this.renderer = new WebGLRenderer({ + devicePixelRatio: window$1.devicePixelRatio, + alpha: false, + clearColor: 0xffffff, + antialias: true + }); + var webglContext = this.renderer.getContext('webgl'); + var oldTexImage2D = webglContext.texImage2D; + /* this is a workaround since threejs uses try catch */ + + webglContext.texImage2D = function () { + try { + for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + + return oldTexImage2D.apply(webglContext, args); + } catch (e) { + _this4.reset(); + + _this4.player_.pause(); + + _this4.triggerError_({ + code: 'web-vr-hls-cors-not-supported', + dismiss: false + }); + + throw new Error(e); + } + }; + + this.renderer.setSize(this.player_.currentWidth(), this.player_.currentHeight(), false); + this.effect = new VREffect(this.renderer); + this.effect.setSize(this.player_.currentWidth(), this.player_.currentHeight(), false); + this.vrDisplay = null; // Previous timestamps for gamepad updates + + this.prevTimestamps_ = []; + this.renderedCanvas = this.renderer.domElement; + this.renderedCanvas.setAttribute('style', 'width: 100%; height: 100%; position: absolute; top:0;'); + var videoElStyle = this.getVideoEl_().style; + this.player_.el().insertBefore(this.renderedCanvas, this.player_.el().firstChild); + videoElStyle.zIndex = '-1'; + videoElStyle.opacity = '0'; + + if (window$1.navigator.getVRDisplays) { + this.log('is supported, getting vr displays'); + window$1.navigator.getVRDisplays().then(function (displays) { + if (displays.length > 0) { + _this4.log('Displays found', displays); + + _this4.vrDisplay = displays[0]; // Native WebVR Head Mounted Displays (HMDs) like the HTC Vive + // also need the cardboard button to enter fully immersive mode + // so, we want to add the button if we're not polyfilled. + + if (!_this4.vrDisplay.isPolyfilled) { + _this4.log('Real HMD found using VRControls', _this4.vrDisplay); + + _this4.addCardboardButton_(); // We use VRControls here since we are working with an HMD + // and we only want orientation controls. + + + _this4.controls3d = new VRControls(_this4.camera); + } + } + + if (!_this4.controls3d) { + _this4.log('no HMD found Using Orbit & Orientation Controls'); + + var options = { + camera: _this4.camera, + canvas: _this4.renderedCanvas, + // check if its a half sphere view projection + halfView: _this4.currentProjection_ === '180', + orientation: videojs.browser.IS_IOS || videojs.browser.IS_ANDROID || false + }; + + if (_this4.options_.motionControls === false) { + options.orientation = false; + } + + _this4.controls3d = new OrbitOrientationControls(options); + _this4.canvasPlayerControls = new CanvasPlayerControls(_this4.player_, _this4.renderedCanvas); + } + + _this4.animationFrameId_ = _this4.requestAnimationFrame(_this4.animate_); + }); + } else if (window$1.navigator.getVRDevices) { + this.triggerError_({ + code: 'web-vr-out-of-date', + dismiss: false + }); + } else { + this.triggerError_({ + code: 'web-vr-not-supported', + dismiss: false + }); + } + + if (this.options_.omnitone) { + var audiocontext = AudioContext.getContext(); + this.omniController = new OmnitoneController(audiocontext, this.options_.omnitone, this.getVideoEl_(), this.options_.omnitoneOptions); + this.omniController.one('audiocontext-suspended', function () { + _this4.player.pause(); + + _this4.player.one('playing', function () { + audiocontext.resume(); + }); + }); + } + + this.on(this.player_, 'fullscreenchange', this.handleResize_); + window$1.addEventListener('vrdisplaypresentchange', this.handleResize_, true); + window$1.addEventListener('resize', this.handleResize_, true); + window$1.addEventListener('vrdisplayactivate', this.handleVrDisplayActivate_, true); + window$1.addEventListener('vrdisplaydeactivate', this.handleVrDisplayDeactivate_, true); + this.initialized_ = true; + this.trigger('initialized'); + }; + + _proto.addCardboardButton_ = function addCardboardButton_() { + if (!this.player_.controlBar.getChild('CardboardButton')) { + this.player_.controlBar.addChild('CardboardButton', {}); + } + }; + + _proto.getVideoEl_ = function getVideoEl_() { + return this.player_.el().getElementsByTagName('video')[0]; + }; + + _proto.reset = function reset() { + if (!this.initialized_) { + return; + } + + if (this.omniController) { + this.omniController.off('audiocontext-suspended'); + this.omniController.dispose(); + this.omniController = undefined; + } + + if (this.controls3d) { + this.controls3d.dispose(); + this.controls3d = null; + } + + if (this.canvasPlayerControls) { + this.canvasPlayerControls.dispose(); + this.canvasPlayerControls = null; + } + + if (this.effect) { + this.effect.dispose(); + this.effect = null; + } + + window$1.removeEventListener('resize', this.handleResize_, true); + window$1.removeEventListener('vrdisplaypresentchange', this.handleResize_, true); + window$1.removeEventListener('vrdisplayactivate', this.handleVrDisplayActivate_, true); + window$1.removeEventListener('vrdisplaydeactivate', this.handleVrDisplayDeactivate_, true); // re-add the big play button to player + + if (!this.player_.getChild('BigPlayButton')) { + this.player_.addChild('BigPlayButton', {}, this.bigPlayButtonIndex_); + } + + if (this.player_.getChild('BigVrPlayButton')) { + this.player_.removeChild('BigVrPlayButton'); + } // remove the cardboard button + + + if (this.player_.getChild('CardboardButton')) { + this.player_.controlBar.removeChild('CardboardButton'); + } // show the fullscreen again + + + if (videojs.browser.IS_IOS) { + this.player_.controlBar.fullscreenToggle.show(); + } // reset the video element style so that it will be displayed + + + var videoElStyle = this.getVideoEl_().style; + videoElStyle.zIndex = ''; + videoElStyle.opacity = ''; // set the current projection to the default + + this.currentProjection_ = this.defaultProjection_; // reset the ios touch to click workaround + + if (this.iosRevertTouchToClick_) { + this.iosRevertTouchToClick_(); + } // remove the old canvas + + + if (this.renderedCanvas) { + this.renderedCanvas.parentNode.removeChild(this.renderedCanvas); + } + + if (this.animationFrameId_) { + this.cancelAnimationFrame(this.animationFrameId_); + } + + this.initialized_ = false; + }; + + _proto.dispose = function dispose() { + _Plugin.prototype.dispose.call(this); + + this.reset(); + }; + + _proto.polyfillVersion = function polyfillVersion() { + return WebVRPolyfill.version; + }; + + return VR; + }(Plugin); + + VR.prototype.setTimeout = Component.prototype.setTimeout; + VR.prototype.clearTimeout = Component.prototype.clearTimeout; + VR.VERSION = version; + videojs.registerPlugin('vr', VR); + + return VR; + +})); diff --git a/assets/js/videojs-vr.min.js b/assets/js/videojs-vr.min.js deleted file mode 100644 index 5b1e5eb9..00000000 --- a/assets/js/videojs-vr.min.js +++ /dev/null @@ -1,176 +0,0 @@ -/*! @name videojs-vr @version 1.7.1 @license Apache-2.0 */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t(require("global/window"),require("global/document"),require("video.js")):"function"==typeof define&&define.amd?define(["global/window","global/document","video.js"],t):(e=e||self).videojsVr=t(e.window,e.document,e.videojs)}(this,function(e,t,i){"use strict";e=e&&e.hasOwnProperty("default")?e.default:e,t=t&&t.hasOwnProperty("default")?t.default:t,i=i&&i.hasOwnProperty("default")?i.default:i;var n=function(e){if(void 0===e)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return e};var r=function(e,t){e.prototype=Object.create(t.prototype),e.prototype.constructor=e,e.__proto__=t},a="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:{};var o,s=function(e,t){return e(t={exports:{}},t.exports),t.exports}(function(e,t){ -/** - * @license - * webvr-polyfill - * Copyright (c) 2015-2017 Google - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -/** - * @license - * cardboard-vr-display - * Copyright (c) 2015-2017 Google - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -/** - * @license - * webvr-polyfill-dpdb - * Copyright (c) 2017 Google - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -/** - * @license - * wglu-preserve-state - * Copyright (c) 2016, Brandon Jones. - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ -/** - * @license - * nosleep.js - * Copyright (c) 2017, Rich Tibbett - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in - * all copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN - * THE SOFTWARE. - */ -e.exports=function(){var e,t="undefined"!=typeof window?window:void 0!==a?a:"undefined"!=typeof self?self:{},i=function(){return/Android/i.test(navigator.userAgent)||/iPhone|iPad|iPod/i.test(navigator.userAgent)},n=function(e,t){for(var i=0,n=e.length;ie.TEXTURE31){console.error("TEXTURE_BINDING_2D or TEXTURE_BINDING_CUBE_MAP must be followed by a valid texture unit"),n.push(null,null);break}r||(r=e.getParameter(e.ACTIVE_TEXTURE)),e.activeTexture(c),n.push(e.getParameter(o),null);break;case e.ACTIVE_TEXTURE:r=e.getParameter(e.ACTIVE_TEXTURE),n.push(null);break;default:n.push(e.getParameter(o))}for(i(e),a=0;ae.TEXTURE31)break;e.activeTexture(c),e.bindTexture(e.TEXTURE_2D,s);break;case e.TEXTURE_BINDING_CUBE_MAP:var c;if((c=t[++a])e.TEXTURE31)break;e.activeTexture(c),e.bindTexture(e.TEXTURE_CUBE_MAP,s);break;case e.VIEWPORT:e.viewport(s[0],s[1],s[2],s[3]);break;case e.BLEND:case e.CULL_FACE:case e.DEPTH_TEST:case e.SCISSOR_TEST:case e.STENCIL_TEST:s?e.enable(o):e.disable(o);break;default:console.log("No GL restore behavior for 0x"+o.toString(16))}r&&e.activeTexture(r)}}else i(e)},b=["attribute vec2 position;","attribute vec3 texCoord;","varying vec2 vTexCoord;","uniform vec4 viewportOffsetScale[2];","void main() {"," vec4 viewport = viewportOffsetScale[int(texCoord.z)];"," vTexCoord = (texCoord.xy * viewport.zw) + viewport.xy;"," gl_Position = vec4( position, 1.0, 1.0 );","}"].join("\n"),I=["precision mediump float;","uniform sampler2D diffuse;","varying vec2 vTexCoord;","void main() {"," gl_FragColor = texture2D(diffuse, vTexCoord);","}"].join("\n");function _(e,t,i,n){this.gl=e,this.cardboardUI=t,this.bufferScale=i,this.dirtySubmitFrameBindings=n,this.ctxAttribs=e.getContextAttributes(),this.meshWidth=20,this.meshHeight=20,this.bufferWidth=e.drawingBufferWidth,this.bufferHeight=e.drawingBufferHeight,this.realBindFramebuffer=e.bindFramebuffer,this.realEnable=e.enable,this.realDisable=e.disable,this.realColorMask=e.colorMask,this.realClearColor=e.clearColor,this.realViewport=e.viewport,c()||(this.realCanvasWidth=Object.getOwnPropertyDescriptor(e.canvas.__proto__,"width"),this.realCanvasHeight=Object.getOwnPropertyDescriptor(e.canvas.__proto__,"height")),this.isPatched=!1,this.lastBoundFramebuffer=null,this.cullFace=!1,this.depthTest=!1,this.blend=!1,this.scissorTest=!1,this.stencilTest=!1,this.viewport=[0,0,0,0],this.colorMask=[!0,!0,!0,!0],this.clearColor=[0,0,0,0],this.attribs={position:0,texCoord:1},this.program=y(e,b,I,this.attribs),this.uniforms=v(e,this.program),this.viewportOffsetScale=new Float32Array(8),this.setTextureBounds(),this.vertexBuffer=e.createBuffer(),this.indexBuffer=e.createBuffer(),this.indexCount=0,this.renderTarget=e.createTexture(),this.framebuffer=e.createFramebuffer(),this.depthStencilBuffer=null,this.depthBuffer=null,this.stencilBuffer=null,this.ctxAttribs.depth&&this.ctxAttribs.stencil?this.depthStencilBuffer=e.createRenderbuffer():this.ctxAttribs.depth?this.depthBuffer=e.createRenderbuffer():this.ctxAttribs.stencil&&(this.stencilBuffer=e.createRenderbuffer()),this.patch(),this.onResize()}_.prototype.destroy=function(){var e=this.gl;this.unpatch(),e.deleteProgram(this.program),e.deleteBuffer(this.vertexBuffer),e.deleteBuffer(this.indexBuffer),e.deleteTexture(this.renderTarget),e.deleteFramebuffer(this.framebuffer),this.depthStencilBuffer&&e.deleteRenderbuffer(this.depthStencilBuffer),this.depthBuffer&&e.deleteRenderbuffer(this.depthBuffer),this.stencilBuffer&&e.deleteRenderbuffer(this.stencilBuffer),this.cardboardUI&&this.cardboardUI.destroy()},_.prototype.onResize=function(){var e=this.gl,t=this,i=[e.RENDERBUFFER_BINDING,e.TEXTURE_BINDING_2D,e.TEXTURE0];L(e,i,function(e){t.realBindFramebuffer.call(e,e.FRAMEBUFFER,null),t.scissorTest&&t.realDisable.call(e,e.SCISSOR_TEST),t.realColorMask.call(e,!0,!0,!0,!0),t.realViewport.call(e,0,0,e.drawingBufferWidth,e.drawingBufferHeight),t.realClearColor.call(e,0,0,0,1),e.clear(e.COLOR_BUFFER_BIT),t.realBindFramebuffer.call(e,e.FRAMEBUFFER,t.framebuffer),e.bindTexture(e.TEXTURE_2D,t.renderTarget),e.texImage2D(e.TEXTURE_2D,0,t.ctxAttribs.alpha?e.RGBA:e.RGB,t.bufferWidth,t.bufferHeight,0,t.ctxAttribs.alpha?e.RGBA:e.RGB,e.UNSIGNED_BYTE,null),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MAG_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_S,e.CLAMP_TO_EDGE),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_T,e.CLAMP_TO_EDGE),e.framebufferTexture2D(e.FRAMEBUFFER,e.COLOR_ATTACHMENT0,e.TEXTURE_2D,t.renderTarget,0),t.ctxAttribs.depth&&t.ctxAttribs.stencil?(e.bindRenderbuffer(e.RENDERBUFFER,t.depthStencilBuffer),e.renderbufferStorage(e.RENDERBUFFER,e.DEPTH_STENCIL,t.bufferWidth,t.bufferHeight),e.framebufferRenderbuffer(e.FRAMEBUFFER,e.DEPTH_STENCIL_ATTACHMENT,e.RENDERBUFFER,t.depthStencilBuffer)):t.ctxAttribs.depth?(e.bindRenderbuffer(e.RENDERBUFFER,t.depthBuffer),e.renderbufferStorage(e.RENDERBUFFER,e.DEPTH_COMPONENT16,t.bufferWidth,t.bufferHeight),e.framebufferRenderbuffer(e.FRAMEBUFFER,e.DEPTH_ATTACHMENT,e.RENDERBUFFER,t.depthBuffer)):t.ctxAttribs.stencil&&(e.bindRenderbuffer(e.RENDERBUFFER,t.stencilBuffer),e.renderbufferStorage(e.RENDERBUFFER,e.STENCIL_INDEX8,t.bufferWidth,t.bufferHeight),e.framebufferRenderbuffer(e.FRAMEBUFFER,e.STENCIL_ATTACHMENT,e.RENDERBUFFER,t.stencilBuffer)),!e.checkFramebufferStatus(e.FRAMEBUFFER)===e.FRAMEBUFFER_COMPLETE&&console.error("Framebuffer incomplete!"),t.realBindFramebuffer.call(e,e.FRAMEBUFFER,t.lastBoundFramebuffer),t.scissorTest&&t.realEnable.call(e,e.SCISSOR_TEST),t.realColorMask.apply(e,t.colorMask),t.realViewport.apply(e,t.viewport),t.realClearColor.apply(e,t.clearColor)}),this.cardboardUI&&this.cardboardUI.onResize()},_.prototype.patch=function(){if(!this.isPatched){var e=this,t=this.gl.canvas,i=this.gl;c()||(t.width=g()*this.bufferScale,t.height=m()*this.bufferScale,Object.defineProperty(t,"width",{configurable:!0,enumerable:!0,get:function(){return e.bufferWidth},set:function(i){e.bufferWidth=i,e.realCanvasWidth.set.call(t,i),e.onResize()}}),Object.defineProperty(t,"height",{configurable:!0,enumerable:!0,get:function(){return e.bufferHeight},set:function(i){e.bufferHeight=i,e.realCanvasHeight.set.call(t,i),e.onResize()}})),this.lastBoundFramebuffer=i.getParameter(i.FRAMEBUFFER_BINDING),null==this.lastBoundFramebuffer&&(this.lastBoundFramebuffer=this.framebuffer,this.gl.bindFramebuffer(i.FRAMEBUFFER,this.framebuffer)),this.gl.bindFramebuffer=function(t,n){e.lastBoundFramebuffer=n||e.framebuffer,e.realBindFramebuffer.call(i,t,e.lastBoundFramebuffer)},this.cullFace=i.getParameter(i.CULL_FACE),this.depthTest=i.getParameter(i.DEPTH_TEST),this.blend=i.getParameter(i.BLEND),this.scissorTest=i.getParameter(i.SCISSOR_TEST),this.stencilTest=i.getParameter(i.STENCIL_TEST),i.enable=function(t){switch(t){case i.CULL_FACE:e.cullFace=!0;break;case i.DEPTH_TEST:e.depthTest=!0;break;case i.BLEND:e.blend=!0;break;case i.SCISSOR_TEST:e.scissorTest=!0;break;case i.STENCIL_TEST:e.stencilTest=!0}e.realEnable.call(i,t)},i.disable=function(t){switch(t){case i.CULL_FACE:e.cullFace=!1;break;case i.DEPTH_TEST:e.depthTest=!1;break;case i.BLEND:e.blend=!1;break;case i.SCISSOR_TEST:e.scissorTest=!1;break;case i.STENCIL_TEST:e.stencilTest=!1}e.realDisable.call(i,t)},this.colorMask=i.getParameter(i.COLOR_WRITEMASK),i.colorMask=function(t,n,r,a){e.colorMask[0]=t,e.colorMask[1]=n,e.colorMask[2]=r,e.colorMask[3]=a,e.realColorMask.call(i,t,n,r,a)},this.clearColor=i.getParameter(i.COLOR_CLEAR_VALUE),i.clearColor=function(t,n,r,a){e.clearColor[0]=t,e.clearColor[1]=n,e.clearColor[2]=r,e.clearColor[3]=a,e.realClearColor.call(i,t,n,r,a)},this.viewport=i.getParameter(i.VIEWPORT),i.viewport=function(t,n,r,a){e.viewport[0]=t,e.viewport[1]=n,e.viewport[2]=r,e.viewport[3]=a,e.realViewport.call(i,t,n,r,a)},this.isPatched=!0,x(t)}},_.prototype.unpatch=function(){if(this.isPatched){var e=this.gl,t=this.gl.canvas;c()||(Object.defineProperty(t,"width",this.realCanvasWidth),Object.defineProperty(t,"height",this.realCanvasHeight)),t.width=this.bufferWidth,t.height=this.bufferHeight,e.bindFramebuffer=this.realBindFramebuffer,e.enable=this.realEnable,e.disable=this.realDisable,e.colorMask=this.realColorMask,e.clearColor=this.realClearColor,e.viewport=this.realViewport,this.lastBoundFramebuffer==this.framebuffer&&e.bindFramebuffer(e.FRAMEBUFFER,null),this.isPatched=!1,setTimeout(function(){x(t)},1)}},_.prototype.setTextureBounds=function(e,t){e||(e=[0,0,.5,1]),t||(t=[.5,0,.5,1]),this.viewportOffsetScale[0]=e[0],this.viewportOffsetScale[1]=e[1],this.viewportOffsetScale[2]=e[2],this.viewportOffsetScale[3]=e[3],this.viewportOffsetScale[4]=t[0],this.viewportOffsetScale[5]=t[1],this.viewportOffsetScale[6]=t[2],this.viewportOffsetScale[7]=t[3]},_.prototype.submitFrame=function(){var e=this.gl,t=this,i=[];if(this.dirtySubmitFrameBindings||i.push(e.CURRENT_PROGRAM,e.ARRAY_BUFFER_BINDING,e.ELEMENT_ARRAY_BUFFER_BINDING,e.TEXTURE_BINDING_2D,e.TEXTURE0),L(e,i,function(e){t.realBindFramebuffer.call(e,e.FRAMEBUFFER,null),t.cullFace&&t.realDisable.call(e,e.CULL_FACE),t.depthTest&&t.realDisable.call(e,e.DEPTH_TEST),t.blend&&t.realDisable.call(e,e.BLEND),t.scissorTest&&t.realDisable.call(e,e.SCISSOR_TEST),t.stencilTest&&t.realDisable.call(e,e.STENCIL_TEST),t.realColorMask.call(e,!0,!0,!0,!0),t.realViewport.call(e,0,0,e.drawingBufferWidth,e.drawingBufferHeight),(t.ctxAttribs.alpha||c())&&(t.realClearColor.call(e,0,0,0,1),e.clear(e.COLOR_BUFFER_BIT)),e.useProgram(t.program),e.bindBuffer(e.ELEMENT_ARRAY_BUFFER,t.indexBuffer),e.bindBuffer(e.ARRAY_BUFFER,t.vertexBuffer),e.enableVertexAttribArray(t.attribs.position),e.enableVertexAttribArray(t.attribs.texCoord),e.vertexAttribPointer(t.attribs.position,2,e.FLOAT,!1,20,0),e.vertexAttribPointer(t.attribs.texCoord,3,e.FLOAT,!1,20,8),e.activeTexture(e.TEXTURE0),e.uniform1i(t.uniforms.diffuse,0),e.bindTexture(e.TEXTURE_2D,t.renderTarget),e.uniform4fv(t.uniforms.viewportOffsetScale,t.viewportOffsetScale),e.drawElements(e.TRIANGLES,t.indexCount,e.UNSIGNED_SHORT,0),t.cardboardUI&&t.cardboardUI.renderNoState(),t.realBindFramebuffer.call(t.gl,e.FRAMEBUFFER,t.framebuffer),t.ctxAttribs.preserveDrawingBuffer||(t.realClearColor.call(e,0,0,0,0),e.clear(e.COLOR_BUFFER_BIT)),t.dirtySubmitFrameBindings||t.realBindFramebuffer.call(e,e.FRAMEBUFFER,t.lastBoundFramebuffer),t.cullFace&&t.realEnable.call(e,e.CULL_FACE),t.depthTest&&t.realEnable.call(e,e.DEPTH_TEST),t.blend&&t.realEnable.call(e,e.BLEND),t.scissorTest&&t.realEnable.call(e,e.SCISSOR_TEST),t.stencilTest&&t.realEnable.call(e,e.STENCIL_TEST),t.realColorMask.apply(e,t.colorMask),t.realViewport.apply(e,t.viewport),!t.ctxAttribs.alpha&&t.ctxAttribs.preserveDrawingBuffer||t.realClearColor.apply(e,t.clearColor)}),c()){var n=e.canvas;n.width==t.bufferWidth&&n.height==t.bufferHeight||(t.bufferWidth=n.width,t.bufferHeight=n.height,t.onResize())}},_.prototype.updateDeviceInfo=function(e){var t=this.gl,i=this,n=[t.ARRAY_BUFFER_BINDING,t.ELEMENT_ARRAY_BUFFER_BINDING];L(t,n,function(t){var n=i.computeMeshVertices_(i.meshWidth,i.meshHeight,e);if(t.bindBuffer(t.ARRAY_BUFFER,i.vertexBuffer),t.bufferData(t.ARRAY_BUFFER,n,t.STATIC_DRAW),!i.indexCount){var r=i.computeMeshIndices_(i.meshWidth,i.meshHeight);t.bindBuffer(t.ELEMENT_ARRAY_BUFFER,i.indexBuffer),t.bufferData(t.ELEMENT_ARRAY_BUFFER,r,t.STATIC_DRAW),i.indexCount=r.length}})},_.prototype.computeMeshVertices_=function(e,t,i){for(var n=new Float32Array(2*e*t*5),r=i.getLeftEyeVisibleTanAngles(),a=i.getLeftEyeNoLensTanAngles(),o=i.getLeftEyeVisibleScreenRect(a),c=0,u=0;u<2;u++){for(var h=0;hr-42&&n.clientXi.clientHeight-42?e(n):n.clientX<42&&n.clientY<42&&t(n)},i.addEventListener("click",this.listener,!1)},z.prototype.onResize=function(){var e=this.gl,t=this,i=[e.ARRAY_BUFFER_BINDING];L(e,i,function(e){var i=[],n=e.drawingBufferWidth/2,r=Math.max(screen.width,screen.height)*window.devicePixelRatio,a=e.drawingBufferWidth/r*window.devicePixelRatio,o=4*a/2,s=42*a,c=28*a/2,u=14*a;function h(e,t){var r=(90-e)*C,a=Math.cos(r),o=Math.sin(r);i.push(O*a*c+n,O*o*c+c),i.push(t*a*c+n,t*o*c+c)}i.push(n-o,s),i.push(n-o,e.drawingBufferHeight),i.push(n+o,s),i.push(n+o,e.drawingBufferHeight),t.gearOffset=i.length/2;for(var l=0;l<=6;l++){var d=60*l;h(d,1),h(d+12,1),h(d+20,.75),h(d+40,.75),h(d+48,1)}function p(t,n){i.push(u+t,e.drawingBufferHeight-u-n)}t.gearVertexCount=i.length/2-t.gearOffset,t.arrowOffset=i.length/2;var f=o/Math.sin(45*C);p(0,c),p(c,0),p(c+f,f),p(f,c+f),p(f,c-f),p(0,c),p(c,2*c),p(c+f,2*c-f),p(f,c-f),p(0,c),p(f,c-o),p(28*a,c-o),p(f,c+o),p(28*a,c+o),t.arrowVertexCount=i.length/2-t.arrowOffset,e.bindBuffer(e.ARRAY_BUFFER,t.vertexBuffer),e.bufferData(e.ARRAY_BUFFER,new Float32Array(i),e.STATIC_DRAW)})},z.prototype.render=function(){var e=this.gl,t=this,i=[e.CULL_FACE,e.DEPTH_TEST,e.BLEND,e.SCISSOR_TEST,e.STENCIL_TEST,e.COLOR_WRITEMASK,e.VIEWPORT,e.CURRENT_PROGRAM,e.ARRAY_BUFFER_BINDING];L(e,i,function(e){e.disable(e.CULL_FACE),e.disable(e.DEPTH_TEST),e.disable(e.BLEND),e.disable(e.SCISSOR_TEST),e.disable(e.STENCIL_TEST),e.colorMask(!0,!0,!0,!0),e.viewport(0,0,e.drawingBufferWidth,e.drawingBufferHeight),t.renderNoState()})},z.prototype.renderNoState=function(){var e,t,i,n,r,a,o,s,c,u,h=this.gl;h.useProgram(this.program),h.bindBuffer(h.ARRAY_BUFFER,this.vertexBuffer),h.enableVertexAttribArray(this.attribs.position),h.vertexAttribPointer(this.attribs.position,2,h.FLOAT,!1,8,0),h.uniform4f(this.uniforms.color,1,1,1,1),e=this.projMat,t=0,i=h.drawingBufferWidth,n=0,r=h.drawingBufferHeight,s=1/(t-i),c=1/(n-r),u=1/((a=.1)-(o=1024)),e[0]=-2*s,e[1]=0,e[2]=0,e[3]=0,e[4]=0,e[5]=-2*c,e[6]=0,e[7]=0,e[8]=0,e[9]=0,e[10]=2*u,e[11]=0,e[12]=(t+i)*s,e[13]=(r+n)*c,e[14]=(o+a)*u,e[15]=1,h.uniformMatrix4fv(this.uniforms.projectionMat,!1,this.projMat),h.drawArrays(h.TRIANGLE_STRIP,0,4),h.drawArrays(h.TRIANGLE_STRIP,this.gearOffset,this.gearVertexCount),h.drawArrays(h.TRIANGLE_STRIP,this.arrowOffset,this.arrowVertexCount)},R.prototype.distortInverse=function(e){for(var t=0,i=1,n=e-this.distort(t);Math.abs(i-t)>1e-4;){var r=e-this.distort(i),a=i-r*((i-t)/(r-n));t=i,i=a,n=r}return i},R.prototype.distort=function(e){for(var t=e*e,i=0,n=0;n=1)return this.w=a,this.x=i,this.y=n,this.z=r,this;var s=Math.acos(o),c=Math.sqrt(1-o*o);if(Math.abs(c)<.001)return this.w=.5*(a+this.w),this.x=.5*(i+this.x),this.y=.5*(n+this.y),this.z=.5*(r+this.z),this;var u=Math.sin((1-t)*s)/c,h=Math.sin(t*s)/c;return this.w=a*u+this.w*h,this.x=i*u+this.x*h,this.y=n*u+this.y*h,this.z=r*u+this.z*h,this},setFromUnitVectors:function(e,t){return void 0===k&&(k=new B),(F=e.dot(t)+1)<1e-6?(F=0,Math.abs(e.x)>Math.abs(e.z)?k.set(-e.y,e.x,0):k.set(0,-e.z,e.y)):k.crossVectors(e,t),this.x=k.x,this.y=k.y,this.z=k.z,this.w=F,this.normalize(),this}};var Y=new Q({widthMeters:.11,heightMeters:.062,bevelMeters:.004}),V=new Q({widthMeters:.1038,heightMeters:.0584,bevelMeters:.004}),H={CardboardV1:new X({id:"CardboardV1",label:"Cardboard I/O 2014",fov:40,interLensDistance:.06,baselineLensDistance:.035,screenLensDistance:.042,distortionCoefficients:[.441,.156],inverseCoefficients:[-.4410035,.42756155,-.4804439,.5460139,-.58821183,.5733938,-.48303202,.33299083,-.17573841,.0651772,-.01488963,.001559834]}),CardboardV2:new X({id:"CardboardV2",label:"Cardboard I/O 2015",fov:60,interLensDistance:.064,baselineLensDistance:.035,screenLensDistance:.039,distortionCoefficients:[.34,.55],inverseCoefficients:[-.33836704,-.18162185,.862655,-1.2462051,1.0560602,-.58208317,.21609078,-.05444823,.009177956,-.0009904169,6183535e-11,-16981803e-13]})};function W(e,t){this.viewer=H.CardboardV2,this.updateDeviceParams(e),this.distortion=new R(this.viewer.distortionCoefficients);for(var i=0;i=200&&i.status<=299?(n.dpdb=JSON.parse(i.response),n.recalculateDeviceParams_()):console.error("Error loading online DPDB!")}),i.send()}}function J(e){this.xdpi=e.xdpi,this.ydpi=e.ydpi,this.bevelMm=e.bevelMm}function K(e,t){this.set(e,t)}function $(e,t){this.kFilter=e,this.isDebug=t,this.currentAccelMeasurement=new K,this.currentGyroMeasurement=new K,this.previousGyroMeasurement=new K,c()?this.filterQ=new G(-1,0,0,1):this.filterQ=new G(1,0,0,1),this.previousFilterQ=new G,this.previousFilterQ.copy(this.filterQ),this.accelQ=new G,this.isOrientationInitialized=!1,this.estimatedGravity=new B,this.measuredGravity=new B,this.gyroIntegralQ=new G}function ee(e,t){this.predictionTimeS=e,this.isDebug=t,this.previousQ=new G,this.previousTimestampS=null,this.deltaQ=new G,this.outQ=new G}function te(e,t,i,n){this.yawOnly=i,this.accelerometer=new B,this.gyroscope=new B,this.filter=new $(e,n),this.posePredictor=new ee(t,n),this.isFirefoxAndroid=h(),this.isIOS=c();var r=l();this.isDeviceMotionInRadians=!this.isIOS&&r&&r<66,this.isWithoutDeviceMotion=d(),this.filterToWorldQ=new G,c()?this.filterToWorldQ.setFromAxisAngle(new B(1,0,0),Math.PI/2):this.filterToWorldQ.setFromAxisAngle(new B(1,0,0),-Math.PI/2),this.inverseWorldToScreenQ=new G,this.worldToScreenQ=new G,this.originalPoseAdjustQ=new G,this.originalPoseAdjustQ.setFromAxisAngle(new B(0,0,1),-window.orientation*Math.PI/180),this.setScreenTransform_(),f()&&this.filterToWorldQ.multiply(this.inverseWorldToScreenQ),this.resetQ=new G,this.orientationOut_=new Float32Array(4),this.start()}q.prototype.getDeviceParams=function(){return this.deviceParams},q.prototype.recalculateDeviceParams_=function(){var e=this.calcDeviceParams_();e?(this.deviceParams=e,this.onDeviceParamsUpdated&&this.onDeviceParamsUpdated(this.deviceParams)):console.error("Failed to recalculate device parameters.")},q.prototype.calcDeviceParams_=function(){var e=this.dpdb;if(!e)return console.error("DPDB not available."),null;if(1!=e.format)return console.error("DPDB has unexpected format version."),null;if(!e.devices||!e.devices.length)return console.error("DPDB does not have a devices section."),null;var t=navigator.userAgent||navigator.vendor||window.opera,i=g(),n=m();if(!e.devices)return console.error("DPDB has no devices section."),null;for(var r=0;r1||this.run_(),this.previousGyroMeasurement.copy(this.currentGyroMeasurement)},$.prototype.run_=function(){if(!this.isOrientationInitialized)return this.accelQ=this.accelToQuaternion_(this.currentAccelMeasurement.sample),this.previousFilterQ.copy(this.accelQ),void(this.isOrientationInitialized=!0);var e=this.currentGyroMeasurement.timestampS-this.previousGyroMeasurement.timestampS,t=this.gyroToQuaternionDelta_(this.currentGyroMeasurement.sample,e);this.gyroIntegralQ.multiply(t),this.filterQ.copy(this.previousFilterQ),this.filterQ.multiply(t);var i=new G;i.copy(this.filterQ),i.inverse(),this.estimatedGravity.set(0,0,-1),this.estimatedGravity.applyQuaternion(i),this.estimatedGravity.normalize(),this.measuredGravity.copy(this.currentAccelMeasurement.sample),this.measuredGravity.normalize();var n,r=new G;r.setFromUnitVectors(this.estimatedGravity,this.measuredGravity),r.inverse(),this.isDebug&&console.log("Delta: %d deg, G_est: (%s, %s, %s), G_meas: (%s, %s, %s)",P*((n=r).w>1?(console.warn("getQuaternionAngle: w > 1"),0):2*Math.acos(n.w)),this.estimatedGravity.x.toFixed(1),this.estimatedGravity.y.toFixed(1),this.estimatedGravity.z.toFixed(1),this.measuredGravity.x.toFixed(1),this.measuredGravity.y.toFixed(1),this.measuredGravity.z.toFixed(1));var a=new G;a.copy(this.filterQ),a.multiply(r),this.filterQ.slerp(a,1-this.kFilter),this.previousFilterQ.copy(this.filterQ)},$.prototype.getOrientation=function(){return this.filterQ},$.prototype.accelToQuaternion_=function(e){var t=new B;t.copy(e),t.normalize();var i=new G;return i.setFromUnitVectors(new B(0,0,-1),t),i.inverse(),i},$.prototype.gyroToQuaternionDelta_=function(e,t){var i=new G,n=new B;return n.copy(e),n.normalize(),i.setFromAxisAngle(n,e.length()*t),i},ee.prototype.getPrediction=function(e,t,i){if(!this.previousTimestampS)return this.previousQ.copy(e),this.previousTimestampS=i,e;var n=new B;n.copy(t),n.normalize();var r=t.length();if(r<20*U)return this.isDebug&&console.log("Moving slowly, at %s deg/s: no prediction",(P*r).toFixed(1)),this.outQ.copy(e),this.previousQ.copy(e),this.outQ;var a=r*this.predictionTimeS;return this.deltaQ.setFromAxisAngle(n,a),this.outQ.copy(this.previousQ),this.outQ.multiply(this.deltaQ),this.previousQ.copy(e),this.previousTimestampS=i,this.outQ},te.prototype.getPosition=function(){return null},te.prototype.getOrientation=function(){var e=void 0;if(this.isWithoutDeviceMotion&&this._deviceOrientationQ)return this.deviceOrientationFixQ=this.deviceOrientationFixQ||(i=(new G).setFromAxisAngle(new B(0,0,-1),0),n=new G,-90===window.orientation?n.setFromAxisAngle(new B(0,1,0),Math.PI/-2):n.setFromAxisAngle(new B(0,1,0),Math.PI/2),i.multiply(n)),this.deviceOrientationFilterToWorldQ=this.deviceOrientationFilterToWorldQ||((t=new G).setFromAxisAngle(new B(1,0,0),-Math.PI/2),t),e=this._deviceOrientationQ,(r=new G).copy(e),r.multiply(this.deviceOrientationFilterToWorldQ),r.multiply(this.resetQ),r.multiply(this.worldToScreenQ),r.multiplyQuaternions(this.deviceOrientationFixQ,r),this.yawOnly&&(r.x=0,r.z=0,r.normalize()),this.orientationOut_[0]=r.x,this.orientationOut_[1]=r.y,this.orientationOut_[2]=r.z,this.orientationOut_[3]=r.w,this.orientationOut_;var t,i,n,r,a=this.filter.getOrientation();return e=this.posePredictor.getPrediction(a,this.gyroscope,this.previousTimestampS),(r=new G).copy(this.filterToWorldQ),r.multiply(this.resetQ),r.multiply(e),r.multiply(this.worldToScreenQ),this.yawOnly&&(r.x=0,r.z=0,r.normalize()),this.orientationOut_[0]=r.x,this.orientationOut_[1]=r.y,this.orientationOut_[2]=r.z,this.orientationOut_[3]=r.w,this.orientationOut_},te.prototype.resetPose=function(){this.resetQ.copy(this.filter.getOrientation()),this.resetQ.x=0,this.resetQ.y=0,this.resetQ.z*=-1,this.resetQ.normalize(),f()&&this.resetQ.multiply(this.inverseWorldToScreenQ),this.resetQ.multiply(this.originalPoseAdjustQ)},te.prototype.onDeviceOrientation_=function(e){this._deviceOrientationQ=this._deviceOrientationQ||new G;var t=e.alpha,i=e.beta,n=e.gamma;t=(t||0)*Math.PI/180,i=(i||0)*Math.PI/180,n=(n||0)*Math.PI/180,this._deviceOrientationQ.setFromEulerYXZ(i,t,-n)},te.prototype.onDeviceMotion_=function(e){this.updateDeviceMotion_(e)},te.prototype.updateDeviceMotion_=function(e){var t=e.accelerationIncludingGravity,i=e.rotationRate,n=e.timeStamp/1e3,r=n-this.previousTimestampS;return r<0?(N("fusion-pose-sensor:invalid:non-monotonic","Invalid timestamps detected: non-monotonic timestamp from devicemotion"),void(this.previousTimestampS=n)):r<=.001||r>1?(N("fusion-pose-sensor:invalid:outside-threshold","Invalid timestamps detected: Timestamp from devicemotion outside expected range."),void(this.previousTimestampS=n)):(this.accelerometer.set(-t.x,-t.y,-t.z),p()?this.gyroscope.set(-i.beta,i.alpha,i.gamma):this.gyroscope.set(i.alpha,i.beta,i.gamma),this.isDeviceMotionInRadians||this.gyroscope.multiplyScalar(Math.PI/180),this.filter.addAccelMeasurement(this.accelerometer,n),this.filter.addGyroMeasurement(this.gyroscope,n),void(this.previousTimestampS=n))},te.prototype.onOrientationChange_=function(e){this.setScreenTransform_()},te.prototype.onMessage_=function(e){var t=e.data;t&&t.type&&"devicemotion"===t.type.toLowerCase()&&this.updateDeviceMotion_(t.deviceMotionEvent)},te.prototype.setScreenTransform_=function(){switch(this.worldToScreenQ.set(0,0,0,1),window.orientation){case 0:break;case 90:this.worldToScreenQ.setFromAxisAngle(new B(0,0,1),-Math.PI/2);break;case-90:this.worldToScreenQ.setFromAxisAngle(new B(0,0,1),Math.PI/2)}this.inverseWorldToScreenQ.copy(this.worldToScreenQ),this.inverseWorldToScreenQ.inverse()},te.prototype.start=function(){var e,t,i;this.onDeviceMotionCallback_=this.onDeviceMotion_.bind(this),this.onOrientationChangeCallback_=this.onOrientationChange_.bind(this),this.onMessageCallback_=this.onMessage_.bind(this),this.onDeviceOrientationCallback_=this.onDeviceOrientation_.bind(this),c()&&(e=window.self!==window.top,t=T(document.referrer),i=T(window.location.href),e&&t!==i)&&window.addEventListener("message",this.onMessageCallback_),window.addEventListener("orientationchange",this.onOrientationChangeCallback_),this.isWithoutDeviceMotion?window.addEventListener("deviceorientation",this.onDeviceOrientationCallback_):window.addEventListener("devicemotion",this.onDeviceMotionCallback_)},te.prototype.stop=function(){window.removeEventListener("devicemotion",this.onDeviceMotionCallback_),window.removeEventListener("deviceorientation",this.onDeviceOrientationCallback_),window.removeEventListener("orientationchange",this.onOrientationChangeCallback_),window.removeEventListener("message",this.onMessageCallback_)};var ie=new B(1,0,0),ne=new B(0,0,1),re={};screen.orientation?re=screen.orientation:screen.msOrientation?re=screen.msOrientation:Object.defineProperty(re,"angle",{get:function(){return window.orientation||0}});var ae=new G;ae.setFromAxisAngle(ie,-Math.PI/2),ae.multiply((new G).setFromAxisAngle(ne,Math.PI/2));var oe=function(){function e(t){r(this,e),this.config=t,this.sensor=null,this.fusionSensor=null,this._out=new Float32Array(4),this.api=null,this.errors=[],this._sensorQ=new G,this._worldToScreenQ=new G,this._outQ=new G,this._onSensorRead=this._onSensorRead.bind(this),this._onSensorError=this._onSensorError.bind(this),this._onOrientationChange=this._onOrientationChange.bind(this),this._onOrientationChange(),this.init()}return a(e,[{key:"init",value:function(){var e=null;try{(e=new RelativeOrientationSensor({frequency:60})).addEventListener("error",this._onSensorError)}catch(e){this.errors.push(e),"SecurityError"===e.name?(console.error("Cannot construct sensors due to the Feature Policy"),console.warn('Attempting to fall back using "devicemotion"; however this will fail in the future without correct permissions.'),this.useDeviceMotion()):"ReferenceError"===e.name?this.useDeviceMotion():console.error(e)}e&&(this.api="sensor",this.sensor=e,this.sensor.addEventListener("reading",this._onSensorRead),this.sensor.start()),window.addEventListener("orientationchange",this._onOrientationChange)}},{key:"useDeviceMotion",value:function(){this.api="devicemotion",this.fusionSensor=new te(this.config.K_FILTER,this.config.PREDICTION_TIME_S,this.config.YAW_ONLY,this.config.DEBUG),this.sensor&&(this.sensor.removeEventListener("reading",this._onSensorRead),this.sensor.removeEventListener("error",this._onSensorError),this.sensor=null)}},{key:"getOrientation",value:function(){if(this.fusionSensor)return this.fusionSensor.getOrientation();if(!this.sensor||!this.sensor.quaternion)return this._out[0]=this._out[1]=this._out[2]=0,this._out[3]=1,this._out;var e=this.sensor.quaternion;this._sensorQ.set(e[0],e[1],e[2],e[3]);var t=this._outQ;return t.copy(ae),t.multiply(this._sensorQ),t.multiply(this._worldToScreenQ),this.config.YAW_ONLY&&(t.x=t.z=0,t.normalize()),this._out[0]=t.x,this._out[1]=t.y,this._out[2]=t.z,this._out[3]=t.w,this._out}},{key:"_onSensorError",value:function(e){this.errors.push(e.error),"NotAllowedError"===e.error.name?console.error("Permission to access sensor was denied"):"NotReadableError"===e.error.name?console.error("Sensor could not be read"):console.error(e.error),this.useDeviceMotion()}},{key:"_onSensorRead",value:function(){}},{key:"_onOrientationChange",value:function(){var e=-re.angle*Math.PI/180;this._worldToScreenQ.setFromAxisAngle(ne,e)}}]),e}();function se(){this.loadIcon_();var e=document.createElement("div");(a=e.style).position="fixed",a.top=0,a.right=0,a.bottom=0,a.left=0,a.backgroundColor="gray",a.fontFamily="sans-serif",a.zIndex=1e6;var t=document.createElement("img");t.src=this.icon,(a=t.style).marginLeft="25%",a.marginTop="25%",a.width="50%",e.appendChild(t);var i=document.createElement("div");(a=i.style).textAlign="center",a.fontSize="16px",a.lineHeight="24px",a.margin="24px 25%",a.width="50%",i.innerHTML="Place your phone into your Cardboard viewer.",e.appendChild(i);var n=document.createElement("div");(a=n.style).backgroundColor="#CFD8DC",a.position="fixed",a.bottom=0,a.width="100%",a.height="48px",a.padding="14px 24px",a.boxSizing="border-box",a.color="#656A6B",e.appendChild(n);var r=document.createElement("div");r.style.float="left",r.innerHTML="No Cardboard viewer?";var a,o=document.createElement("a");o.href="https://www.google.com/get/cardboard/get-cardboard/",o.innerHTML="get one",o.target="_blank",(a=o.style).float="right",a.fontWeight=600,a.textTransform="uppercase",a.borderLeft="1px solid gray",a.paddingLeft="24px",a.textDecoration="none",a.color="#656A6B",n.appendChild(r),n.appendChild(o),this.overlay=e,this.text=i,this.hide()}se.prototype.show=function(e){e||this.overlay.parentElement?e&&(this.overlay.parentElement&&this.overlay.parentElement!=e&&this.overlay.parentElement.removeChild(this.overlay),e.appendChild(this.overlay)):document.body.appendChild(this.overlay),this.overlay.style.display="block";var t=this.overlay.querySelector("img").style;f()?(t.width="20%",t.marginLeft="40%",t.marginTop="3%"):(t.width="50%",t.marginLeft="25%",t.marginTop="25%")},se.prototype.hide=function(){this.overlay.style.display="none"},se.prototype.showTemporarily=function(e,t){this.show(t),this.timer=setTimeout(this.hide.bind(this),e)},se.prototype.disableShowTemporarily=function(){clearTimeout(this.timer)},se.prototype.update=function(){this.disableShowTemporarily(),!f()&&A()?this.show():this.hide()},se.prototype.loadIcon_=function(){this.icon="data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+Cjxzdmcgd2lkdGg9IjE5OHB4IiBoZWlnaHQ9IjI0MHB4IiB2aWV3Qm94PSIwIDAgMTk4IDI0MCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWxuczpza2V0Y2g9Imh0dHA6Ly93d3cuYm9oZW1pYW5jb2RpbmcuY29tL3NrZXRjaC9ucyI+CiAgICA8IS0tIEdlbmVyYXRvcjogU2tldGNoIDMuMy4zICgxMjA4MSkgLSBodHRwOi8vd3d3LmJvaGVtaWFuY29kaW5nLmNvbS9za2V0Y2ggLS0+CiAgICA8dGl0bGU+dHJhbnNpdGlvbjwvdGl0bGU+CiAgICA8ZGVzYz5DcmVhdGVkIHdpdGggU2tldGNoLjwvZGVzYz4KICAgIDxkZWZzPjwvZGVmcz4KICAgIDxnIGlkPSJQYWdlLTEiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHNrZXRjaDp0eXBlPSJNU1BhZ2UiPgogICAgICAgIDxnIGlkPSJ0cmFuc2l0aW9uIiBza2V0Y2g6dHlwZT0iTVNBcnRib2FyZEdyb3VwIj4KICAgICAgICAgICAgPGcgaWQ9IkltcG9ydGVkLUxheWVycy1Db3B5LTQtKy1JbXBvcnRlZC1MYXllcnMtQ29weS0rLUltcG9ydGVkLUxheWVycy1Db3B5LTItQ29weSIgc2tldGNoOnR5cGU9Ik1TTGF5ZXJHcm91cCI+CiAgICAgICAgICAgICAgICA8ZyBpZD0iSW1wb3J0ZWQtTGF5ZXJzLUNvcHktNCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMC4wMDAwMDAsIDEwNy4wMDAwMDApIiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTQ5LjYyNSwyLjUyNyBDMTQ5LjYyNSwyLjUyNyAxNTUuODA1LDYuMDk2IDE1Ni4zNjIsNi40MTggTDE1Ni4zNjIsNy4zMDQgQzE1Ni4zNjIsNy40ODEgMTU2LjM3NSw3LjY2NCAxNTYuNCw3Ljg1MyBDMTU2LjQxLDcuOTM0IDE1Ni40Miw4LjAxNSAxNTYuNDI3LDguMDk1IEMxNTYuNTY3LDkuNTEgMTU3LjQwMSwxMS4wOTMgMTU4LjUzMiwxMi4wOTQgTDE2NC4yNTIsMTcuMTU2IEwxNjQuMzMzLDE3LjA2NiBDMTY0LjMzMywxNy4wNjYgMTY4LjcxNSwxNC41MzYgMTY5LjU2OCwxNC4wNDIgQzE3MS4wMjUsMTQuODgzIDE5NS41MzgsMjkuMDM1IDE5NS41MzgsMjkuMDM1IEwxOTUuNTM4LDgzLjAzNiBDMTk1LjUzOCw4My44MDcgMTk1LjE1Miw4NC4yNTMgMTk0LjU5LDg0LjI1MyBDMTk0LjM1Nyw4NC4yNTMgMTk0LjA5NSw4NC4xNzcgMTkzLjgxOCw4NC4wMTcgTDE2OS44NTEsNzAuMTc5IEwxNjkuODM3LDcwLjIwMyBMMTQyLjUxNSw4NS45NzggTDE0MS42NjUsODQuNjU1IEMxMzYuOTM0LDgzLjEyNiAxMzEuOTE3LDgxLjkxNSAxMjYuNzE0LDgxLjA0NSBDMTI2LjcwOSw4MS4wNiAxMjYuNzA3LDgxLjA2OSAxMjYuNzA3LDgxLjA2OSBMMTIxLjY0LDk4LjAzIEwxMTMuNzQ5LDEwMi41ODYgTDExMy43MTIsMTAyLjUyMyBMMTEzLjcxMiwxMzAuMTEzIEMxMTMuNzEyLDEzMC44ODUgMTEzLjMyNiwxMzEuMzMgMTEyLjc2NCwxMzEuMzMgQzExMi41MzIsMTMxLjMzIDExMi4yNjksMTMxLjI1NCAxMTEuOTkyLDEzMS4wOTQgTDY5LjUxOSwxMDYuNTcyIEM2OC41NjksMTA2LjAyMyA2Ny43OTksMTA0LjY5NSA2Ny43OTksMTAzLjYwNSBMNjcuNzk5LDEwMi41NyBMNjcuNzc4LDEwMi42MTcgQzY3LjI3LDEwMi4zOTMgNjYuNjQ4LDEwMi4yNDkgNjUuOTYyLDEwMi4yMTggQzY1Ljg3NSwxMDIuMjE0IDY1Ljc4OCwxMDIuMjEyIDY1LjcwMSwxMDIuMjEyIEM2NS42MDYsMTAyLjIxMiA2NS41MTEsMTAyLjIxNSA2NS40MTYsMTAyLjIxOSBDNjUuMTk1LDEwMi4yMjkgNjQuOTc0LDEwMi4yMzUgNjQuNzU0LDEwMi4yMzUgQzY0LjMzMSwxMDIuMjM1IDYzLjkxMSwxMDIuMjE2IDYzLjQ5OCwxMDIuMTc4IEM2MS44NDMsMTAyLjAyNSA2MC4yOTgsMTAxLjU3OCA1OS4wOTQsMTAwLjg4MiBMMTIuNTE4LDczLjk5MiBMMTIuNTIzLDc0LjAwNCBMMi4yNDUsNTUuMjU0IEMxLjI0NCw1My40MjcgMi4wMDQsNTEuMDM4IDMuOTQzLDQ5LjkxOCBMNTkuOTU0LDE3LjU3MyBDNjAuNjI2LDE3LjE4NSA2MS4zNSwxNy4wMDEgNjIuMDUzLDE3LjAwMSBDNjMuMzc5LDE3LjAwMSA2NC42MjUsMTcuNjYgNjUuMjgsMTguODU0IEw2NS4yODUsMTguODUxIEw2NS41MTIsMTkuMjY0IEw2NS41MDYsMTkuMjY4IEM2NS45MDksMjAuMDAzIDY2LjQwNSwyMC42OCA2Ni45ODMsMjEuMjg2IEw2Ny4yNiwyMS41NTYgQzY5LjE3NCwyMy40MDYgNzEuNzI4LDI0LjM1NyA3NC4zNzMsMjQuMzU3IEM3Ni4zMjIsMjQuMzU3IDc4LjMyMSwyMy44NCA4MC4xNDgsMjIuNzg1IEM4MC4xNjEsMjIuNzg1IDg3LjQ2NywxOC41NjYgODcuNDY3LDE4LjU2NiBDODguMTM5LDE4LjE3OCA4OC44NjMsMTcuOTk0IDg5LjU2NiwxNy45OTQgQzkwLjg5MiwxNy45OTQgOTIuMTM4LDE4LjY1MiA5Mi43OTIsMTkuODQ3IEw5Ni4wNDIsMjUuNzc1IEw5Ni4wNjQsMjUuNzU3IEwxMDIuODQ5LDI5LjY3NCBMMTAyLjc0NCwyOS40OTIgTDE0OS42MjUsMi41MjcgTTE0OS42MjUsMC44OTIgQzE0OS4zNDMsMC44OTIgMTQ5LjA2MiwwLjk2NSAxNDguODEsMS4xMSBMMTAyLjY0MSwyNy42NjYgTDk3LjIzMSwyNC41NDIgTDk0LjIyNiwxOS4wNjEgQzkzLjMxMywxNy4zOTQgOTEuNTI3LDE2LjM1OSA4OS41NjYsMTYuMzU4IEM4OC41NTUsMTYuMzU4IDg3LjU0NiwxNi42MzIgODYuNjQ5LDE3LjE1IEM4My44NzgsMTguNzUgNzkuNjg3LDIxLjE2OSA3OS4zNzQsMjEuMzQ1IEM3OS4zNTksMjEuMzUzIDc5LjM0NSwyMS4zNjEgNzkuMzMsMjEuMzY5IEM3Ny43OTgsMjIuMjU0IDc2LjA4NCwyMi43MjIgNzQuMzczLDIyLjcyMiBDNzIuMDgxLDIyLjcyMiA2OS45NTksMjEuODkgNjguMzk3LDIwLjM4IEw2OC4xNDUsMjAuMTM1IEM2Ny43MDYsMTkuNjcyIDY3LjMyMywxOS4xNTYgNjcuMDA2LDE4LjYwMSBDNjYuOTg4LDE4LjU1OSA2Ni45NjgsMTguNTE5IDY2Ljk0NiwxOC40NzkgTDY2LjcxOSwxOC4wNjUgQzY2LjY5LDE4LjAxMiA2Ni42NTgsMTcuOTYgNjYuNjI0LDE3LjkxMSBDNjUuNjg2LDE2LjMzNyA2My45NTEsMTUuMzY2IDYyLjA1MywxNS4zNjYgQzYxLjA0MiwxNS4zNjYgNjAuMDMzLDE1LjY0IDU5LjEzNiwxNi4xNTggTDMuMTI1LDQ4LjUwMiBDMC40MjYsNTAuMDYxIC0wLjYxMyw1My40NDIgMC44MTEsNTYuMDQgTDExLjA4OSw3NC43OSBDMTEuMjY2LDc1LjExMyAxMS41MzcsNzUuMzUzIDExLjg1LDc1LjQ5NCBMNTguMjc2LDEwMi4yOTggQzU5LjY3OSwxMDMuMTA4IDYxLjQzMywxMDMuNjMgNjMuMzQ4LDEwMy44MDYgQzYzLjgxMiwxMDMuODQ4IDY0LjI4NSwxMDMuODcgNjQuNzU0LDEwMy44NyBDNjUsMTAzLjg3IDY1LjI0OSwxMDMuODY0IDY1LjQ5NCwxMDMuODUyIEM2NS41NjMsMTAzLjg0OSA2NS42MzIsMTAzLjg0NyA2NS43MDEsMTAzLjg0NyBDNjUuNzY0LDEwMy44NDcgNjUuODI4LDEwMy44NDkgNjUuODksMTAzLjg1MiBDNjUuOTg2LDEwMy44NTYgNjYuMDgsMTAzLjg2MyA2Ni4xNzMsMTAzLjg3NCBDNjYuMjgyLDEwNS40NjcgNjcuMzMyLDEwNy4xOTcgNjguNzAyLDEwNy45ODggTDExMS4xNzQsMTMyLjUxIEMxMTEuNjk4LDEzMi44MTIgMTEyLjIzMiwxMzIuOTY1IDExMi43NjQsMTMyLjk2NSBDMTE0LjI2MSwxMzIuOTY1IDExNS4zNDcsMTMxLjc2NSAxMTUuMzQ3LDEzMC4xMTMgTDExNS4zNDcsMTAzLjU1MSBMMTIyLjQ1OCw5OS40NDYgQzEyMi44MTksOTkuMjM3IDEyMy4wODcsOTguODk4IDEyMy4yMDcsOTguNDk4IEwxMjcuODY1LDgyLjkwNSBDMTMyLjI3OSw4My43MDIgMTM2LjU1Nyw4NC43NTMgMTQwLjYwNyw4Ni4wMzMgTDE0MS4xNCw4Ni44NjIgQzE0MS40NTEsODcuMzQ2IDE0MS45NzcsODcuNjEzIDE0Mi41MTYsODcuNjEzIEMxNDIuNzk0LDg3LjYxMyAxNDMuMDc2LDg3LjU0MiAxNDMuMzMzLDg3LjM5MyBMMTY5Ljg2NSw3Mi4wNzYgTDE5Myw4NS40MzMgQzE5My41MjMsODUuNzM1IDE5NC4wNTgsODUuODg4IDE5NC41OSw4NS44ODggQzE5Ni4wODcsODUuODg4IDE5Ny4xNzMsODQuNjg5IDE5Ny4xNzMsODMuMDM2IEwxOTcuMTczLDI5LjAzNSBDMTk3LjE3MywyOC40NTEgMTk2Ljg2MSwyNy45MTEgMTk2LjM1NSwyNy42MTkgQzE5Ni4zNTUsMjcuNjE5IDE3MS44NDMsMTMuNDY3IDE3MC4zODUsMTIuNjI2IEMxNzAuMTMyLDEyLjQ4IDE2OS44NSwxMi40MDcgMTY5LjU2OCwxMi40MDcgQzE2OS4yODUsMTIuNDA3IDE2OS4wMDIsMTIuNDgxIDE2OC43NDksMTIuNjI3IEMxNjguMTQzLDEyLjk3OCAxNjUuNzU2LDE0LjM1NyAxNjQuNDI0LDE1LjEyNSBMMTU5LjYxNSwxMC44NyBDMTU4Ljc5NiwxMC4xNDUgMTU4LjE1NCw4LjkzNyAxNTguMDU0LDcuOTM0IEMxNTguMDQ1LDcuODM3IDE1OC4wMzQsNy43MzkgMTU4LjAyMSw3LjY0IEMxNTguMDA1LDcuNTIzIDE1Ny45OTgsNy40MSAxNTcuOTk4LDcuMzA0IEwxNTcuOTk4LDYuNDE4IEMxNTcuOTk4LDUuODM0IDE1Ny42ODYsNS4yOTUgMTU3LjE4MSw1LjAwMiBDMTU2LjYyNCw0LjY4IDE1MC40NDIsMS4xMTEgMTUwLjQ0MiwxLjExMSBDMTUwLjE4OSwwLjk2NSAxNDkuOTA3LDAuODkyIDE0OS42MjUsMC44OTIiIGlkPSJGaWxsLTEiIGZpbGw9IiM0NTVBNjQiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNOTYuMDI3LDI1LjYzNiBMMTQyLjYwMyw1Mi41MjcgQzE0My44MDcsNTMuMjIyIDE0NC41ODIsNTQuMTE0IDE0NC44NDUsNTUuMDY4IEwxNDQuODM1LDU1LjA3NSBMNjMuNDYxLDEwMi4wNTcgTDYzLjQ2LDEwMi4wNTcgQzYxLjgwNiwxMDEuOTA1IDYwLjI2MSwxMDEuNDU3IDU5LjA1NywxMDAuNzYyIEwxMi40ODEsNzMuODcxIEw5Ni4wMjcsMjUuNjM2IiBpZD0iRmlsbC0yIiBmaWxsPSIjRkFGQUZBIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTYzLjQ2MSwxMDIuMTc0IEM2My40NTMsMTAyLjE3NCA2My40NDYsMTAyLjE3NCA2My40MzksMTAyLjE3MiBDNjEuNzQ2LDEwMi4wMTYgNjAuMjExLDEwMS41NjMgNTguOTk4LDEwMC44NjMgTDEyLjQyMiw3My45NzMgQzEyLjM4Niw3My45NTIgMTIuMzY0LDczLjkxNCAxMi4zNjQsNzMuODcxIEMxMi4zNjQsNzMuODMgMTIuMzg2LDczLjc5MSAxMi40MjIsNzMuNzcgTDk1Ljk2OCwyNS41MzUgQzk2LjAwNCwyNS41MTQgOTYuMDQ5LDI1LjUxNCA5Ni4wODUsMjUuNTM1IEwxNDIuNjYxLDUyLjQyNiBDMTQzLjg4OCw1My4xMzQgMTQ0LjY4Miw1NC4wMzggMTQ0Ljk1Nyw1NS4wMzcgQzE0NC45Nyw1NS4wODMgMTQ0Ljk1Myw1NS4xMzMgMTQ0LjkxNSw1NS4xNjEgQzE0NC45MTEsNTUuMTY1IDE0NC44OTgsNTUuMTc0IDE0NC44OTQsNTUuMTc3IEw2My41MTksMTAyLjE1OCBDNjMuNTAxLDEwMi4xNjkgNjMuNDgxLDEwMi4xNzQgNjMuNDYxLDEwMi4xNzQgTDYzLjQ2MSwxMDIuMTc0IFogTTEyLjcxNCw3My44NzEgTDU5LjExNSwxMDAuNjYxIEM2MC4yOTMsMTAxLjM0MSA2MS43ODYsMTAxLjc4MiA2My40MzUsMTAxLjkzNyBMMTQ0LjcwNyw1NS4wMTUgQzE0NC40MjgsNTQuMTA4IDE0My42ODIsNTMuMjg1IDE0Mi41NDQsNTIuNjI4IEw5Ni4wMjcsMjUuNzcxIEwxMi43MTQsNzMuODcxIEwxMi43MTQsNzMuODcxIFoiIGlkPSJGaWxsLTMiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTQ4LjMyNyw1OC40NzEgQzE0OC4xNDUsNTguNDggMTQ3Ljk2Miw1OC40OCAxNDcuNzgxLDU4LjQ3MiBDMTQ1Ljg4Nyw1OC4zODkgMTQ0LjQ3OSw1Ny40MzQgMTQ0LjYzNiw1Ni4zNCBDMTQ0LjY4OSw1NS45NjcgMTQ0LjY2NCw1NS41OTcgMTQ0LjU2NCw1NS4yMzUgTDYzLjQ2MSwxMDIuMDU3IEM2NC4wODksMTAyLjExNSA2NC43MzMsMTAyLjEzIDY1LjM3OSwxMDIuMDk5IEM2NS41NjEsMTAyLjA5IDY1Ljc0MywxMDIuMDkgNjUuOTI1LDEwMi4wOTggQzY3LjgxOSwxMDIuMTgxIDY5LjIyNywxMDMuMTM2IDY5LjA3LDEwNC4yMyBMMTQ4LjMyNyw1OC40NzEiIGlkPSJGaWxsLTQiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNjkuMDcsMTA0LjM0NyBDNjkuMDQ4LDEwNC4zNDcgNjkuMDI1LDEwNC4zNCA2OS4wMDUsMTA0LjMyNyBDNjguOTY4LDEwNC4zMDEgNjguOTQ4LDEwNC4yNTcgNjguOTU1LDEwNC4yMTMgQzY5LDEwMy44OTYgNjguODk4LDEwMy41NzYgNjguNjU4LDEwMy4yODggQzY4LjE1MywxMDIuNjc4IDY3LjEwMywxMDIuMjY2IDY1LjkyLDEwMi4yMTQgQzY1Ljc0MiwxMDIuMjA2IDY1LjU2MywxMDIuMjA3IDY1LjM4NSwxMDIuMjE1IEM2NC43NDIsMTAyLjI0NiA2NC4wODcsMTAyLjIzMiA2My40NSwxMDIuMTc0IEM2My4zOTksMTAyLjE2OSA2My4zNTgsMTAyLjEzMiA2My4zNDcsMTAyLjA4MiBDNjMuMzM2LDEwMi4wMzMgNjMuMzU4LDEwMS45ODEgNjMuNDAyLDEwMS45NTYgTDE0NC41MDYsNTUuMTM0IEMxNDQuNTM3LDU1LjExNiAxNDQuNTc1LDU1LjExMyAxNDQuNjA5LDU1LjEyNyBDMTQ0LjY0Miw1NS4xNDEgMTQ0LjY2OCw1NS4xNyAxNDQuNjc3LDU1LjIwNCBDMTQ0Ljc4MSw1NS41ODUgMTQ0LjgwNiw1NS45NzIgMTQ0Ljc1MSw1Ni4zNTcgQzE0NC43MDYsNTYuNjczIDE0NC44MDgsNTYuOTk0IDE0NS4wNDcsNTcuMjgyIEMxNDUuNTUzLDU3Ljg5MiAxNDYuNjAyLDU4LjMwMyAxNDcuNzg2LDU4LjM1NSBDMTQ3Ljk2NCw1OC4zNjMgMTQ4LjE0Myw1OC4zNjMgMTQ4LjMyMSw1OC4zNTQgQzE0OC4zNzcsNTguMzUyIDE0OC40MjQsNTguMzg3IDE0OC40MzksNTguNDM4IEMxNDguNDU0LDU4LjQ5IDE0OC40MzIsNTguNTQ1IDE0OC4zODUsNTguNTcyIEw2OS4xMjksMTA0LjMzMSBDNjkuMTExLDEwNC4zNDIgNjkuMDksMTA0LjM0NyA2OS4wNywxMDQuMzQ3IEw2OS4wNywxMDQuMzQ3IFogTTY1LjY2NSwxMDEuOTc1IEM2NS43NTQsMTAxLjk3NSA2NS44NDIsMTAxLjk3NyA2NS45MywxMDEuOTgxIEM2Ny4xOTYsMTAyLjAzNyA2OC4yODMsMTAyLjQ2OSA2OC44MzgsMTAzLjEzOSBDNjkuMDY1LDEwMy40MTMgNjkuMTg4LDEwMy43MTQgNjkuMTk4LDEwNC4wMjEgTDE0Ny44ODMsNTguNTkyIEMxNDcuODQ3LDU4LjU5MiAxNDcuODExLDU4LjU5MSAxNDcuNzc2LDU4LjU4OSBDMTQ2LjUwOSw1OC41MzMgMTQ1LjQyMiw1OC4xIDE0NC44NjcsNTcuNDMxIEMxNDQuNTg1LDU3LjA5MSAxNDQuNDY1LDU2LjcwNyAxNDQuNTIsNTYuMzI0IEMxNDQuNTYzLDU2LjAyMSAxNDQuNTUyLDU1LjcxNiAxNDQuNDg4LDU1LjQxNCBMNjMuODQ2LDEwMS45NyBDNjQuMzUzLDEwMi4wMDIgNjQuODY3LDEwMi4wMDYgNjUuMzc0LDEwMS45ODIgQzY1LjQ3MSwxMDEuOTc3IDY1LjU2OCwxMDEuOTc1IDY1LjY2NSwxMDEuOTc1IEw2NS42NjUsMTAxLjk3NSBaIiBpZD0iRmlsbC01IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTIuMjA4LDU1LjEzNCBDMS4yMDcsNTMuMzA3IDEuOTY3LDUwLjkxNyAzLjkwNiw0OS43OTcgTDU5LjkxNywxNy40NTMgQzYxLjg1NiwxNi4zMzMgNjQuMjQxLDE2LjkwNyA2NS4yNDMsMTguNzM0IEw2NS40NzUsMTkuMTQ0IEM2NS44NzIsMTkuODgyIDY2LjM2OCwyMC41NiA2Ni45NDUsMjEuMTY1IEw2Ny4yMjMsMjEuNDM1IEM3MC41NDgsMjQuNjQ5IDc1LjgwNiwyNS4xNTEgODAuMTExLDIyLjY2NSBMODcuNDMsMTguNDQ1IEM4OS4zNywxNy4zMjYgOTEuNzU0LDE3Ljg5OSA5Mi43NTUsMTkuNzI3IEw5Ni4wMDUsMjUuNjU1IEwxMi40ODYsNzMuODg0IEwyLjIwOCw1NS4xMzQgWiIgaWQ9IkZpbGwtNiIgZmlsbD0iI0ZBRkFGQSI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMi40ODYsNzQuMDAxIEMxMi40NzYsNzQuMDAxIDEyLjQ2NSw3My45OTkgMTIuNDU1LDczLjk5NiBDMTIuNDI0LDczLjk4OCAxMi4zOTksNzMuOTY3IDEyLjM4NCw3My45NCBMMi4xMDYsNTUuMTkgQzEuMDc1LDUzLjMxIDEuODU3LDUwLjg0NSAzLjg0OCw0OS42OTYgTDU5Ljg1OCwxNy4zNTIgQzYwLjUyNSwxNi45NjcgNjEuMjcxLDE2Ljc2NCA2Mi4wMTYsMTYuNzY0IEM2My40MzEsMTYuNzY0IDY0LjY2NiwxNy40NjYgNjUuMzI3LDE4LjY0NiBDNjUuMzM3LDE4LjY1NCA2NS4zNDUsMTguNjYzIDY1LjM1MSwxOC42NzQgTDY1LjU3OCwxOS4wODggQzY1LjU4NCwxOS4xIDY1LjU4OSwxOS4xMTIgNjUuNTkxLDE5LjEyNiBDNjUuOTg1LDE5LjgzOCA2Ni40NjksMjAuNDk3IDY3LjAzLDIxLjA4NSBMNjcuMzA1LDIxLjM1MSBDNjkuMTUxLDIzLjEzNyA3MS42NDksMjQuMTIgNzQuMzM2LDI0LjEyIEM3Ni4zMTMsMjQuMTIgNzguMjksMjMuNTgyIDgwLjA1MywyMi41NjMgQzgwLjA2NCwyMi41NTcgODAuMDc2LDIyLjU1MyA4MC4wODgsMjIuNTUgTDg3LjM3MiwxOC4zNDQgQzg4LjAzOCwxNy45NTkgODguNzg0LDE3Ljc1NiA4OS41MjksMTcuNzU2IEM5MC45NTYsMTcuNzU2IDkyLjIwMSwxOC40NzIgOTIuODU4LDE5LjY3IEw5Ni4xMDcsMjUuNTk5IEM5Ni4xMzgsMjUuNjU0IDk2LjExOCwyNS43MjQgOTYuMDYzLDI1Ljc1NiBMMTIuNTQ1LDczLjk4NSBDMTIuNTI2LDczLjk5NiAxMi41MDYsNzQuMDAxIDEyLjQ4Niw3NC4wMDEgTDEyLjQ4Niw3NC4wMDEgWiBNNjIuMDE2LDE2Ljk5NyBDNjEuMzEyLDE2Ljk5NyA2MC42MDYsMTcuMTkgNTkuOTc1LDE3LjU1NCBMMy45NjUsNDkuODk5IEMyLjA4Myw1MC45ODUgMS4zNDEsNTMuMzA4IDIuMzEsNTUuMDc4IEwxMi41MzEsNzMuNzIzIEw5NS44NDgsMjUuNjExIEw5Mi42NTMsMTkuNzgyIEM5Mi4wMzgsMTguNjYgOTAuODcsMTcuOTkgODkuNTI5LDE3Ljk5IEM4OC44MjUsMTcuOTkgODguMTE5LDE4LjE4MiA4Ny40ODksMTguNTQ3IEw4MC4xNzIsMjIuNzcyIEM4MC4xNjEsMjIuNzc4IDgwLjE0OSwyMi43ODIgODAuMTM3LDIyLjc4NSBDNzguMzQ2LDIzLjgxMSA3Ni4zNDEsMjQuMzU0IDc0LjMzNiwyNC4zNTQgQzcxLjU4OCwyNC4zNTQgNjkuMDMzLDIzLjM0NyA2Ny4xNDIsMjEuNTE5IEw2Ni44NjQsMjEuMjQ5IEM2Ni4yNzcsMjAuNjM0IDY1Ljc3NCwxOS45NDcgNjUuMzY3LDE5LjIwMyBDNjUuMzYsMTkuMTkyIDY1LjM1NiwxOS4xNzkgNjUuMzU0LDE5LjE2NiBMNjUuMTYzLDE4LjgxOSBDNjUuMTU0LDE4LjgxMSA2NS4xNDYsMTguODAxIDY1LjE0LDE4Ljc5IEM2NC41MjUsMTcuNjY3IDYzLjM1NywxNi45OTcgNjIuMDE2LDE2Ljk5NyBMNjIuMDE2LDE2Ljk5NyBaIiBpZD0iRmlsbC03IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTQyLjQzNCw0OC44MDggTDQyLjQzNCw0OC44MDggQzM5LjkyNCw0OC44MDcgMzcuNzM3LDQ3LjU1IDM2LjU4Miw0NS40NDMgQzM0Ljc3MSw0Mi4xMzkgMzYuMTQ0LDM3LjgwOSAzOS42NDEsMzUuNzg5IEw1MS45MzIsMjguNjkxIEM1My4xMDMsMjguMDE1IDU0LjQxMywyNy42NTggNTUuNzIxLDI3LjY1OCBDNTguMjMxLDI3LjY1OCA2MC40MTgsMjguOTE2IDYxLjU3MywzMS4wMjMgQzYzLjM4NCwzNC4zMjcgNjIuMDEyLDM4LjY1NyA1OC41MTQsNDAuNjc3IEw0Ni4yMjMsNDcuNzc1IEM0NS4wNTMsNDguNDUgNDMuNzQyLDQ4LjgwOCA0Mi40MzQsNDguODA4IEw0Mi40MzQsNDguODA4IFogTTU1LjcyMSwyOC4xMjUgQzU0LjQ5NSwyOC4xMjUgNTMuMjY1LDI4LjQ2MSA1Mi4xNjYsMjkuMDk2IEwzOS44NzUsMzYuMTk0IEMzNi41OTYsMzguMDg3IDM1LjMwMiw0Mi4xMzYgMzYuOTkyLDQ1LjIxOCBDMzguMDYzLDQ3LjE3MyA0MC4wOTgsNDguMzQgNDIuNDM0LDQ4LjM0IEM0My42NjEsNDguMzQgNDQuODksNDguMDA1IDQ1Ljk5LDQ3LjM3IEw1OC4yODEsNDAuMjcyIEM2MS41NiwzOC4zNzkgNjIuODUzLDM0LjMzIDYxLjE2NCwzMS4yNDggQzYwLjA5MiwyOS4yOTMgNTguMDU4LDI4LjEyNSA1NS43MjEsMjguMTI1IEw1NS43MjEsMjguMTI1IFoiIGlkPSJGaWxsLTgiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTQ5LjU4OCwyLjQwNyBDMTQ5LjU4OCwyLjQwNyAxNTUuNzY4LDUuOTc1IDE1Ni4zMjUsNi4yOTcgTDE1Ni4zMjUsNy4xODQgQzE1Ni4zMjUsNy4zNiAxNTYuMzM4LDcuNTQ0IDE1Ni4zNjIsNy43MzMgQzE1Ni4zNzMsNy44MTQgMTU2LjM4Miw3Ljg5NCAxNTYuMzksNy45NzUgQzE1Ni41Myw5LjM5IDE1Ny4zNjMsMTAuOTczIDE1OC40OTUsMTEuOTc0IEwxNjUuODkxLDE4LjUxOSBDMTY2LjA2OCwxOC42NzUgMTY2LjI0OSwxOC44MTQgMTY2LjQzMiwxOC45MzQgQzE2OC4wMTEsMTkuOTc0IDE2OS4zODIsMTkuNCAxNjkuNDk0LDE3LjY1MiBDMTY5LjU0MywxNi44NjggMTY5LjU1MSwxNi4wNTcgMTY5LjUxNywxNS4yMjMgTDE2OS41MTQsMTUuMDYzIEwxNjkuNTE0LDEzLjkxMiBDMTcwLjc4LDE0LjY0MiAxOTUuNTAxLDI4LjkxNSAxOTUuNTAxLDI4LjkxNSBMMTk1LjUwMSw4Mi45MTUgQzE5NS41MDEsODQuMDA1IDE5NC43MzEsODQuNDQ1IDE5My43ODEsODMuODk3IEwxNTEuMzA4LDU5LjM3NCBDMTUwLjM1OCw1OC44MjYgMTQ5LjU4OCw1Ny40OTcgMTQ5LjU4OCw1Ni40MDggTDE0OS41ODgsMjIuMzc1IiBpZD0iRmlsbC05IiBmaWxsPSIjRkFGQUZBIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE5NC41NTMsODQuMjUgQzE5NC4yOTYsODQuMjUgMTk0LjAxMyw4NC4xNjUgMTkzLjcyMiw4My45OTcgTDE1MS4yNSw1OS40NzYgQzE1MC4yNjksNTguOTA5IDE0OS40NzEsNTcuNTMzIDE0OS40NzEsNTYuNDA4IEwxNDkuNDcxLDIyLjM3NSBMMTQ5LjcwNSwyMi4zNzUgTDE0OS43MDUsNTYuNDA4IEMxNDkuNzA1LDU3LjQ1OSAxNTAuNDUsNTguNzQ0IDE1MS4zNjYsNTkuMjc0IEwxOTMuODM5LDgzLjc5NSBDMTk0LjI2Myw4NC4wNCAxOTQuNjU1LDg0LjA4MyAxOTQuOTQyLDgzLjkxNyBDMTk1LjIyNyw4My43NTMgMTk1LjM4NCw4My4zOTcgMTk1LjM4NCw4Mi45MTUgTDE5NS4zODQsMjguOTgyIEMxOTQuMTAyLDI4LjI0MiAxNzIuMTA0LDE1LjU0MiAxNjkuNjMxLDE0LjExNCBMMTY5LjYzNCwxNS4yMiBDMTY5LjY2OCwxNi4wNTIgMTY5LjY2LDE2Ljg3NCAxNjkuNjEsMTcuNjU5IEMxNjkuNTU2LDE4LjUwMyAxNjkuMjE0LDE5LjEyMyAxNjguNjQ3LDE5LjQwNSBDMTY4LjAyOCwxOS43MTQgMTY3LjE5NywxOS41NzggMTY2LjM2NywxOS4wMzIgQzE2Ni4xODEsMTguOTA5IDE2NS45OTUsMTguNzY2IDE2NS44MTQsMTguNjA2IEwxNTguNDE3LDEyLjA2MiBDMTU3LjI1OSwxMS4wMzYgMTU2LjQxOCw5LjQzNyAxNTYuMjc0LDcuOTg2IEMxNTYuMjY2LDcuOTA3IDE1Ni4yNTcsNy44MjcgMTU2LjI0Nyw3Ljc0OCBDMTU2LjIyMSw3LjU1NSAxNTYuMjA5LDcuMzY1IDE1Ni4yMDksNy4xODQgTDE1Ni4yMDksNi4zNjQgQzE1NS4zNzUsNS44ODMgMTQ5LjUyOSwyLjUwOCAxNDkuNTI5LDIuNTA4IEwxNDkuNjQ2LDIuMzA2IEMxNDkuNjQ2LDIuMzA2IDE1NS44MjcsNS44NzQgMTU2LjM4NCw2LjE5NiBMMTU2LjQ0Miw2LjIzIEwxNTYuNDQyLDcuMTg0IEMxNTYuNDQyLDcuMzU1IDE1Ni40NTQsNy41MzUgMTU2LjQ3OCw3LjcxNyBDMTU2LjQ4OSw3LjggMTU2LjQ5OSw3Ljg4MiAxNTYuNTA3LDcuOTYzIEMxNTYuNjQ1LDkuMzU4IDE1Ny40NTUsMTAuODk4IDE1OC41NzIsMTEuODg2IEwxNjUuOTY5LDE4LjQzMSBDMTY2LjE0MiwxOC41ODQgMTY2LjMxOSwxOC43MiAxNjYuNDk2LDE4LjgzNyBDMTY3LjI1NCwxOS4zMzYgMTY4LDE5LjQ2NyAxNjguNTQzLDE5LjE5NiBDMTY5LjAzMywxOC45NTMgMTY5LjMyOSwxOC40MDEgMTY5LjM3NywxNy42NDUgQzE2OS40MjcsMTYuODY3IDE2OS40MzQsMTYuMDU0IDE2OS40MDEsMTUuMjI4IEwxNjkuMzk3LDE1LjA2NSBMMTY5LjM5NywxMy43MSBMMTY5LjU3MiwxMy44MSBDMTcwLjgzOSwxNC41NDEgMTk1LjU1OSwyOC44MTQgMTk1LjU1OSwyOC44MTQgTDE5NS42MTgsMjguODQ3IEwxOTUuNjE4LDgyLjkxNSBDMTk1LjYxOCw4My40ODQgMTk1LjQyLDgzLjkxMSAxOTUuMDU5LDg0LjExOSBDMTk0LjkwOCw4NC4yMDYgMTk0LjczNyw4NC4yNSAxOTQuNTUzLDg0LjI1IiBpZD0iRmlsbC0xMCIgZmlsbD0iIzYwN0Q4QiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNDUuNjg1LDU2LjE2MSBMMTY5LjgsNzAuMDgzIEwxNDMuODIyLDg1LjA4MSBMMTQyLjM2LDg0Ljc3NCBDMTM1LjgyNiw4Mi42MDQgMTI4LjczMiw4MS4wNDYgMTIxLjM0MSw4MC4xNTggQzExNi45NzYsNzkuNjM0IDExMi42NzgsODEuMjU0IDExMS43NDMsODMuNzc4IEMxMTEuNTA2LDg0LjQxNCAxMTEuNTAzLDg1LjA3MSAxMTEuNzMyLDg1LjcwNiBDMTEzLjI3LDg5Ljk3MyAxMTUuOTY4LDk0LjA2OSAxMTkuNzI3LDk3Ljg0MSBMMTIwLjI1OSw5OC42ODYgQzEyMC4yNiw5OC42ODUgOTQuMjgyLDExMy42ODMgOTQuMjgyLDExMy42ODMgTDcwLjE2Nyw5OS43NjEgTDE0NS42ODUsNTYuMTYxIiBpZD0iRmlsbC0xMSIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik05NC4yODIsMTEzLjgxOCBMOTQuMjIzLDExMy43ODUgTDY5LjkzMyw5OS43NjEgTDcwLjEwOCw5OS42NiBMMTQ1LjY4NSw1Ni4wMjYgTDE0NS43NDMsNTYuMDU5IEwxNzAuMDMzLDcwLjA4MyBMMTQzLjg0Miw4NS4yMDUgTDE0My43OTcsODUuMTk1IEMxNDMuNzcyLDg1LjE5IDE0Mi4zMzYsODQuODg4IDE0Mi4zMzYsODQuODg4IEMxMzUuNzg3LDgyLjcxNCAxMjguNzIzLDgxLjE2MyAxMjEuMzI3LDgwLjI3NCBDMTIwLjc4OCw4MC4yMDkgMTIwLjIzNiw4MC4xNzcgMTE5LjY4OSw4MC4xNzcgQzExNS45MzEsODAuMTc3IDExMi42MzUsODEuNzA4IDExMS44NTIsODMuODE5IEMxMTEuNjI0LDg0LjQzMiAxMTEuNjIxLDg1LjA1MyAxMTEuODQyLDg1LjY2NyBDMTEzLjM3Nyw4OS45MjUgMTE2LjA1OCw5My45OTMgMTE5LjgxLDk3Ljc1OCBMMTE5LjgyNiw5Ny43NzkgTDEyMC4zNTIsOTguNjE0IEMxMjAuMzU0LDk4LjYxNyAxMjAuMzU2LDk4LjYyIDEyMC4zNTgsOTguNjI0IEwxMjAuNDIyLDk4LjcyNiBMMTIwLjMxNyw5OC43ODcgQzEyMC4yNjQsOTguODE4IDk0LjU5OSwxMTMuNjM1IDk0LjM0LDExMy43ODUgTDk0LjI4MiwxMTMuODE4IEw5NC4yODIsMTEzLjgxOCBaIE03MC40MDEsOTkuNzYxIEw5NC4yODIsMTEzLjU0OSBMMTE5LjA4NCw5OS4yMjkgQzExOS42Myw5OC45MTQgMTE5LjkzLDk4Ljc0IDEyMC4xMDEsOTguNjU0IEwxMTkuNjM1LDk3LjkxNCBDMTE1Ljg2NCw5NC4xMjcgMTEzLjE2OCw5MC4wMzMgMTExLjYyMiw4NS43NDYgQzExMS4zODIsODUuMDc5IDExMS4zODYsODQuNDA0IDExMS42MzMsODMuNzM4IEMxMTIuNDQ4LDgxLjUzOSAxMTUuODM2LDc5Ljk0MyAxMTkuNjg5LDc5Ljk0MyBDMTIwLjI0Niw3OS45NDMgMTIwLjgwNiw3OS45NzYgMTIxLjM1NSw4MC4wNDIgQzEyOC43NjcsODAuOTMzIDEzNS44NDYsODIuNDg3IDE0Mi4zOTYsODQuNjYzIEMxNDMuMjMyLDg0LjgzOCAxNDMuNjExLDg0LjkxNyAxNDMuNzg2LDg0Ljk2NyBMMTY5LjU2Niw3MC4wODMgTDE0NS42ODUsNTYuMjk1IEw3MC40MDEsOTkuNzYxIEw3MC40MDEsOTkuNzYxIFoiIGlkPSJGaWxsLTEyIiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2Ny4yMywxOC45NzkgTDE2Ny4yMyw2OS44NSBMMTM5LjkwOSw4NS42MjMgTDEzMy40NDgsNzEuNDU2IEMxMzIuNTM4LDY5LjQ2IDEzMC4wMiw2OS43MTggMTI3LjgyNCw3Mi4wMyBDMTI2Ljc2OSw3My4xNCAxMjUuOTMxLDc0LjU4NSAxMjUuNDk0LDc2LjA0OCBMMTE5LjAzNCw5Ny42NzYgTDkxLjcxMiwxMTMuNDUgTDkxLjcxMiw2Mi41NzkgTDE2Ny4yMywxOC45NzkiIGlkPSJGaWxsLTEzIiBmaWxsPSIjRkZGRkZGIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTkxLjcxMiwxMTMuNTY3IEM5MS42OTIsMTEzLjU2NyA5MS42NzIsMTEzLjU2MSA5MS42NTMsMTEzLjU1MSBDOTEuNjE4LDExMy41MyA5MS41OTUsMTEzLjQ5MiA5MS41OTUsMTEzLjQ1IEw5MS41OTUsNjIuNTc5IEM5MS41OTUsNjIuNTM3IDkxLjYxOCw2Mi40OTkgOTEuNjUzLDYyLjQ3OCBMMTY3LjE3MiwxOC44NzggQzE2Ny4yMDgsMTguODU3IDE2Ny4yNTIsMTguODU3IDE2Ny4yODgsMTguODc4IEMxNjcuMzI0LDE4Ljg5OSAxNjcuMzQ3LDE4LjkzNyAxNjcuMzQ3LDE4Ljk3OSBMMTY3LjM0Nyw2OS44NSBDMTY3LjM0Nyw2OS44OTEgMTY3LjMyNCw2OS45MyAxNjcuMjg4LDY5Ljk1IEwxMzkuOTY3LDg1LjcyNSBDMTM5LjkzOSw4NS43NDEgMTM5LjkwNSw4NS43NDUgMTM5Ljg3Myw4NS43MzUgQzEzOS44NDIsODUuNzI1IDEzOS44MTYsODUuNzAyIDEzOS44MDIsODUuNjcyIEwxMzMuMzQyLDcxLjUwNCBDMTMyLjk2Nyw3MC42ODIgMTMyLjI4LDcwLjIyOSAxMzEuNDA4LDcwLjIyOSBDMTMwLjMxOSw3MC4yMjkgMTI5LjA0NCw3MC45MTUgMTI3LjkwOCw3Mi4xMSBDMTI2Ljg3NCw3My4yIDEyNi4wMzQsNzQuNjQ3IDEyNS42MDYsNzYuMDgyIEwxMTkuMTQ2LDk3LjcwOSBDMTE5LjEzNyw5Ny43MzggMTE5LjExOCw5Ny43NjIgMTE5LjA5Miw5Ny43NzcgTDkxLjc3LDExMy41NTEgQzkxLjc1MiwxMTMuNTYxIDkxLjczMiwxMTMuNTY3IDkxLjcxMiwxMTMuNTY3IEw5MS43MTIsMTEzLjU2NyBaIE05MS44MjksNjIuNjQ3IEw5MS44MjksMTEzLjI0OCBMMTE4LjkzNSw5Ny41OTggTDEyNS4zODIsNzYuMDE1IEMxMjUuODI3LDc0LjUyNSAxMjYuNjY0LDczLjA4MSAxMjcuNzM5LDcxLjk1IEMxMjguOTE5LDcwLjcwOCAxMzAuMjU2LDY5Ljk5NiAxMzEuNDA4LDY5Ljk5NiBDMTMyLjM3Nyw2OS45OTYgMTMzLjEzOSw3MC40OTcgMTMzLjU1NCw3MS40MDcgTDEzOS45NjEsODUuNDU4IEwxNjcuMTEzLDY5Ljc4MiBMMTY3LjExMywxOS4xODEgTDkxLjgyOSw2Mi42NDcgTDkxLjgyOSw2Mi42NDcgWiIgaWQ9IkZpbGwtMTQiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTY4LjU0MywxOS4yMTMgTDE2OC41NDMsNzAuMDgzIEwxNDEuMjIxLDg1Ljg1NyBMMTM0Ljc2MSw3MS42ODkgQzEzMy44NTEsNjkuNjk0IDEzMS4zMzMsNjkuOTUxIDEyOS4xMzcsNzIuMjYzIEMxMjguMDgyLDczLjM3NCAxMjcuMjQ0LDc0LjgxOSAxMjYuODA3LDc2LjI4MiBMMTIwLjM0Niw5Ny45MDkgTDkzLjAyNSwxMTMuNjgzIEw5My4wMjUsNjIuODEzIEwxNjguNTQzLDE5LjIxMyIgaWQ9IkZpbGwtMTUiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNOTMuMDI1LDExMy44IEM5My4wMDUsMTEzLjggOTIuOTg0LDExMy43OTUgOTIuOTY2LDExMy43ODUgQzkyLjkzMSwxMTMuNzY0IDkyLjkwOCwxMTMuNzI1IDkyLjkwOCwxMTMuNjg0IEw5Mi45MDgsNjIuODEzIEM5Mi45MDgsNjIuNzcxIDkyLjkzMSw2Mi43MzMgOTIuOTY2LDYyLjcxMiBMMTY4LjQ4NCwxOS4xMTIgQzE2OC41MiwxOS4wOSAxNjguNTY1LDE5LjA5IDE2OC42MDEsMTkuMTEyIEMxNjguNjM3LDE5LjEzMiAxNjguNjYsMTkuMTcxIDE2OC42NiwxOS4yMTIgTDE2OC42Niw3MC4wODMgQzE2OC42Niw3MC4xMjUgMTY4LjYzNyw3MC4xNjQgMTY4LjYwMSw3MC4xODQgTDE0MS4yOCw4NS45NTggQzE0MS4yNTEsODUuOTc1IDE0MS4yMTcsODUuOTc5IDE0MS4xODYsODUuOTY4IEMxNDEuMTU0LDg1Ljk1OCAxNDEuMTI5LDg1LjkzNiAxNDEuMTE1LDg1LjkwNiBMMTM0LjY1NSw3MS43MzggQzEzNC4yOCw3MC45MTUgMTMzLjU5Myw3MC40NjMgMTMyLjcyLDcwLjQ2MyBDMTMxLjYzMiw3MC40NjMgMTMwLjM1Nyw3MS4xNDggMTI5LjIyMSw3Mi4zNDQgQzEyOC4xODYsNzMuNDMzIDEyNy4zNDcsNzQuODgxIDEyNi45MTksNzYuMzE1IEwxMjAuNDU4LDk3Ljk0MyBDMTIwLjQ1LDk3Ljk3MiAxMjAuNDMxLDk3Ljk5NiAxMjAuNDA1LDk4LjAxIEw5My4wODMsMTEzLjc4NSBDOTMuMDY1LDExMy43OTUgOTMuMDQ1LDExMy44IDkzLjAyNSwxMTMuOCBMOTMuMDI1LDExMy44IFogTTkzLjE0Miw2Mi44ODEgTDkzLjE0MiwxMTMuNDgxIEwxMjAuMjQ4LDk3LjgzMiBMMTI2LjY5NSw3Ni4yNDggQzEyNy4xNCw3NC43NTggMTI3Ljk3Nyw3My4zMTUgMTI5LjA1Miw3Mi4xODMgQzEzMC4yMzEsNzAuOTQyIDEzMS41NjgsNzAuMjI5IDEzMi43Miw3MC4yMjkgQzEzMy42ODksNzAuMjI5IDEzNC40NTIsNzAuNzMxIDEzNC44NjcsNzEuNjQxIEwxNDEuMjc0LDg1LjY5MiBMMTY4LjQyNiw3MC4wMTYgTDE2OC40MjYsMTkuNDE1IEw5My4xNDIsNjIuODgxIEw5My4xNDIsNjIuODgxIFoiIGlkPSJGaWxsLTE2IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2OS44LDcwLjA4MyBMMTQyLjQ3OCw4NS44NTcgTDEzNi4wMTgsNzEuNjg5IEMxMzUuMTA4LDY5LjY5NCAxMzIuNTksNjkuOTUxIDEzMC4zOTMsNzIuMjYzIEMxMjkuMzM5LDczLjM3NCAxMjguNSw3NC44MTkgMTI4LjA2NCw3Ni4yODIgTDEyMS42MDMsOTcuOTA5IEw5NC4yODIsMTEzLjY4MyBMOTQuMjgyLDYyLjgxMyBMMTY5LjgsMTkuMjEzIEwxNjkuOCw3MC4wODMgWiIgaWQ9IkZpbGwtMTciIGZpbGw9IiNGQUZBRkEiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNOTQuMjgyLDExMy45MTcgQzk0LjI0MSwxMTMuOTE3IDk0LjIwMSwxMTMuOTA3IDk0LjE2NSwxMTMuODg2IEM5NC4wOTMsMTEzLjg0NSA5NC4wNDgsMTEzLjc2NyA5NC4wNDgsMTEzLjY4NCBMOTQuMDQ4LDYyLjgxMyBDOTQuMDQ4LDYyLjczIDk0LjA5Myw2Mi42NTIgOTQuMTY1LDYyLjYxMSBMMTY5LjY4MywxOS4wMSBDMTY5Ljc1NSwxOC45NjkgMTY5Ljg0NCwxOC45NjkgMTY5LjkxNywxOS4wMSBDMTY5Ljk4OSwxOS4wNTIgMTcwLjAzMywxOS4xMjkgMTcwLjAzMywxOS4yMTIgTDE3MC4wMzMsNzAuMDgzIEMxNzAuMDMzLDcwLjE2NiAxNjkuOTg5LDcwLjI0NCAxNjkuOTE3LDcwLjI4NSBMMTQyLjU5NSw4Ni4wNiBDMTQyLjUzOCw4Ni4wOTIgMTQyLjQ2OSw4Ni4xIDE0Mi40MDcsODYuMDggQzE0Mi4zNDQsODYuMDYgMTQyLjI5Myw4Ni4wMTQgMTQyLjI2Niw4NS45NTQgTDEzNS44MDUsNzEuNzg2IEMxMzUuNDQ1LDcwLjk5NyAxMzQuODEzLDcwLjU4IDEzMy45NzcsNzAuNTggQzEzMi45MjEsNzAuNTggMTMxLjY3Niw3MS4yNTIgMTMwLjU2Miw3Mi40MjQgQzEyOS41NCw3My41MDEgMTI4LjcxMSw3NC45MzEgMTI4LjI4Nyw3Ni4zNDggTDEyMS44MjcsOTcuOTc2IEMxMjEuODEsOTguMDM0IDEyMS43NzEsOTguMDgyIDEyMS43Miw5OC4xMTIgTDk0LjM5OCwxMTMuODg2IEM5NC4zNjIsMTEzLjkwNyA5NC4zMjIsMTEzLjkxNyA5NC4yODIsMTEzLjkxNyBMOTQuMjgyLDExMy45MTcgWiBNOTQuNTE1LDYyLjk0OCBMOTQuNTE1LDExMy4yNzkgTDEyMS40MDYsOTcuNzU0IEwxMjcuODQsNzYuMjE1IEMxMjguMjksNzQuNzA4IDEyOS4xMzcsNzMuMjQ3IDEzMC4yMjQsNzIuMTAzIEMxMzEuNDI1LDcwLjgzOCAxMzIuNzkzLDcwLjExMiAxMzMuOTc3LDcwLjExMiBDMTM0Ljk5NSw3MC4xMTIgMTM1Ljc5NSw3MC42MzggMTM2LjIzLDcxLjU5MiBMMTQyLjU4NCw4NS41MjYgTDE2OS41NjYsNjkuOTQ4IEwxNjkuNTY2LDE5LjYxNyBMOTQuNTE1LDYyLjk0OCBMOTQuNTE1LDYyLjk0OCBaIiBpZD0iRmlsbC0xOCIgZmlsbD0iIzYwN0Q4QiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMDkuODk0LDkyLjk0MyBMMTA5Ljg5NCw5Mi45NDMgQzEwOC4xMiw5Mi45NDMgMTA2LjY1Myw5Mi4yMTggMTA1LjY1LDkwLjgyMyBDMTA1LjU4Myw5MC43MzEgMTA1LjU5Myw5MC42MSAxMDUuNjczLDkwLjUyOSBDMTA1Ljc1Myw5MC40NDggMTA1Ljg4LDkwLjQ0IDEwNS45NzQsOTAuNTA2IEMxMDYuNzU0LDkxLjA1MyAxMDcuNjc5LDkxLjMzMyAxMDguNzI0LDkxLjMzMyBDMTEwLjA0Nyw5MS4zMzMgMTExLjQ3OCw5MC44OTQgMTEyLjk4LDkwLjAyNyBDMTE4LjI5MSw4Ni45NiAxMjIuNjExLDc5LjUwOSAxMjIuNjExLDczLjQxNiBDMTIyLjYxMSw3MS40ODkgMTIyLjE2OSw2OS44NTYgMTIxLjMzMyw2OC42OTIgQzEyMS4yNjYsNjguNiAxMjEuMjc2LDY4LjQ3MyAxMjEuMzU2LDY4LjM5MiBDMTIxLjQzNiw2OC4zMTEgMTIxLjU2Myw2OC4yOTkgMTIxLjY1Niw2OC4zNjUgQzEyMy4zMjcsNjkuNTM3IDEyNC4yNDcsNzEuNzQ2IDEyNC4yNDcsNzQuNTg0IEMxMjQuMjQ3LDgwLjgyNiAxMTkuODIxLDg4LjQ0NyAxMTQuMzgyLDkxLjU4NyBDMTEyLjgwOCw5Mi40OTUgMTExLjI5OCw5Mi45NDMgMTA5Ljg5NCw5Mi45NDMgTDEwOS44OTQsOTIuOTQzIFogTTEwNi45MjUsOTEuNDAxIEMxMDcuNzM4LDkyLjA1MiAxMDguNzQ1LDkyLjI3OCAxMDkuODkzLDkyLjI3OCBMMTA5Ljg5NCw5Mi4yNzggQzExMS4yMTUsOTIuMjc4IDExMi42NDcsOTEuOTUxIDExNC4xNDgsOTEuMDg0IEMxMTkuNDU5LDg4LjAxNyAxMjMuNzgsODAuNjIxIDEyMy43OCw3NC41MjggQzEyMy43OCw3Mi41NDkgMTIzLjMxNyw3MC45MjkgMTIyLjQ1NCw2OS43NjcgQzEyMi44NjUsNzAuODAyIDEyMy4wNzksNzIuMDQyIDEyMy4wNzksNzMuNDAyIEMxMjMuMDc5LDc5LjY0NSAxMTguNjUzLDg3LjI4NSAxMTMuMjE0LDkwLjQyNSBDMTExLjY0LDkxLjMzNCAxMTAuMTMsOTEuNzQyIDEwOC43MjQsOTEuNzQyIEMxMDguMDgzLDkxLjc0MiAxMDcuNDgxLDkxLjU5MyAxMDYuOTI1LDkxLjQwMSBMMTA2LjkyNSw5MS40MDEgWiIgaWQ9IkZpbGwtMTkiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTEzLjA5Nyw5MC4yMyBDMTE4LjQ4MSw4Ny4xMjIgMTIyLjg0NSw3OS41OTQgMTIyLjg0NSw3My40MTYgQzEyMi44NDUsNzEuMzY1IDEyMi4zNjIsNjkuNzI0IDEyMS41MjIsNjguNTU2IEMxMTkuNzM4LDY3LjMwNCAxMTcuMTQ4LDY3LjM2MiAxMTQuMjY1LDY5LjAyNiBDMTA4Ljg4MSw3Mi4xMzQgMTA0LjUxNyw3OS42NjIgMTA0LjUxNyw4NS44NCBDMTA0LjUxNyw4Ny44OTEgMTA1LDg5LjUzMiAxMDUuODQsOTAuNyBDMTA3LjYyNCw5MS45NTIgMTEwLjIxNCw5MS44OTQgMTEzLjA5Nyw5MC4yMyIgaWQ9IkZpbGwtMjAiIGZpbGw9IiNGQUZBRkEiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTA4LjcyNCw5MS42MTQgTDEwOC43MjQsOTEuNjE0IEMxMDcuNTgyLDkxLjYxNCAxMDYuNTY2LDkxLjQwMSAxMDUuNzA1LDkwLjc5NyBDMTA1LjY4NCw5MC43ODMgMTA1LjY2NSw5MC44MTEgMTA1LjY1LDkwLjc5IEMxMDQuNzU2LDg5LjU0NiAxMDQuMjgzLDg3Ljg0MiAxMDQuMjgzLDg1LjgxNyBDMTA0LjI4Myw3OS41NzUgMTA4LjcwOSw3MS45NTMgMTE0LjE0OCw2OC44MTIgQzExNS43MjIsNjcuOTA0IDExNy4yMzIsNjcuNDQ5IDExOC42MzgsNjcuNDQ5IEMxMTkuNzgsNjcuNDQ5IDEyMC43OTYsNjcuNzU4IDEyMS42NTYsNjguMzYyIEMxMjEuNjc4LDY4LjM3NyAxMjEuNjk3LDY4LjM5NyAxMjEuNzEyLDY4LjQxOCBDMTIyLjYwNiw2OS42NjIgMTIzLjA3OSw3MS4zOSAxMjMuMDc5LDczLjQxNSBDMTIzLjA3OSw3OS42NTggMTE4LjY1Myw4Ny4xOTggMTEzLjIxNCw5MC4zMzggQzExMS42NCw5MS4yNDcgMTEwLjEzLDkxLjYxNCAxMDguNzI0LDkxLjYxNCBMMTA4LjcyNCw5MS42MTQgWiBNMTA2LjAwNiw5MC41MDUgQzEwNi43OCw5MS4wMzcgMTA3LjY5NCw5MS4yODEgMTA4LjcyNCw5MS4yODEgQzExMC4wNDcsOTEuMjgxIDExMS40NzgsOTAuODY4IDExMi45OCw5MC4wMDEgQzExOC4yOTEsODYuOTM1IDEyMi42MTEsNzkuNDk2IDEyMi42MTEsNzMuNDAzIEMxMjIuNjExLDcxLjQ5NCAxMjIuMTc3LDY5Ljg4IDEyMS4zNTYsNjguNzE4IEMxMjAuNTgyLDY4LjE4NSAxMTkuNjY4LDY3LjkxOSAxMTguNjM4LDY3LjkxOSBDMTE3LjMxNSw2Ny45MTkgMTE1Ljg4Myw2OC4zNiAxMTQuMzgyLDY5LjIyNyBDMTA5LjA3MSw3Mi4yOTMgMTA0Ljc1MSw3OS43MzMgMTA0Ljc1MSw4NS44MjYgQzEwNC43NTEsODcuNzM1IDEwNS4xODUsODkuMzQzIDEwNi4wMDYsOTAuNTA1IEwxMDYuMDA2LDkwLjUwNSBaIiBpZD0iRmlsbC0yMSIgZmlsbD0iIzYwN0Q4QiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNDkuMzE4LDcuMjYyIEwxMzkuMzM0LDE2LjE0IEwxNTUuMjI3LDI3LjE3MSBMMTYwLjgxNiwyMS4wNTkgTDE0OS4zMTgsNy4yNjIiIGlkPSJGaWxsLTIyIiBmaWxsPSIjRkFGQUZBIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE2OS42NzYsMTMuODQgTDE1OS45MjgsMTkuNDY3IEMxNTYuMjg2LDIxLjU3IDE1MC40LDIxLjU4IDE0Ni43ODEsMTkuNDkxIEMxNDMuMTYxLDE3LjQwMiAxNDMuMTgsMTQuMDAzIDE0Ni44MjIsMTEuOSBMMTU2LjMxNyw2LjI5MiBMMTQ5LjU4OCwyLjQwNyBMNjcuNzUyLDQ5LjQ3OCBMMTEzLjY3NSw3NS45OTIgTDExNi43NTYsNzQuMjEzIEMxMTcuMzg3LDczLjg0OCAxMTcuNjI1LDczLjMxNSAxMTcuMzc0LDcyLjgyMyBDMTE1LjAxNyw2OC4xOTEgMTE0Ljc4MSw2My4yNzcgMTE2LjY5MSw1OC41NjEgQzEyMi4zMjksNDQuNjQxIDE0MS4yLDMzLjc0NiAxNjUuMzA5LDMwLjQ5MSBDMTczLjQ3OCwyOS4zODggMTgxLjk4OSwyOS41MjQgMTkwLjAxMywzMC44ODUgQzE5MC44NjUsMzEuMDMgMTkxLjc4OSwzMC44OTMgMTkyLjQyLDMwLjUyOCBMMTk1LjUwMSwyOC43NSBMMTY5LjY3NiwxMy44NCIgaWQ9IkZpbGwtMjMiIGZpbGw9IiNGQUZBRkEiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTEzLjY3NSw3Ni40NTkgQzExMy41OTQsNzYuNDU5IDExMy41MTQsNzYuNDM4IDExMy40NDIsNzYuMzk3IEw2Ny41MTgsNDkuODgyIEM2Ny4zNzQsNDkuNzk5IDY3LjI4NCw0OS42NDUgNjcuMjg1LDQ5LjQ3OCBDNjcuMjg1LDQ5LjMxMSA2Ny4zNzQsNDkuMTU3IDY3LjUxOSw0OS4wNzMgTDE0OS4zNTUsMi4wMDIgQzE0OS40OTksMS45MTkgMTQ5LjY3NywxLjkxOSAxNDkuODIxLDIuMDAyIEwxNTYuNTUsNS44ODcgQzE1Ni43NzQsNi4wMTcgMTU2Ljg1LDYuMzAyIDE1Ni43MjIsNi41MjYgQzE1Ni41OTIsNi43NDkgMTU2LjMwNyw2LjgyNiAxNTYuMDgzLDYuNjk2IEwxNDkuNTg3LDIuOTQ2IEw2OC42ODcsNDkuNDc5IEwxMTMuNjc1LDc1LjQ1MiBMMTE2LjUyMyw3My44MDggQzExNi43MTUsNzMuNjk3IDExNy4xNDMsNzMuMzk5IDExNi45NTgsNzMuMDM1IEMxMTQuNTQyLDY4LjI4NyAxMTQuMyw2My4yMjEgMTE2LjI1OCw1OC4zODUgQzExOS4wNjQsNTEuNDU4IDEyNS4xNDMsNDUuMTQzIDEzMy44NCw0MC4xMjIgQzE0Mi40OTcsMzUuMTI0IDE1My4zNTgsMzEuNjMzIDE2NS4yNDcsMzAuMDI4IEMxNzMuNDQ1LDI4LjkyMSAxODIuMDM3LDI5LjA1OCAxOTAuMDkxLDMwLjQyNSBDMTkwLjgzLDMwLjU1IDE5MS42NTIsMzAuNDMyIDE5Mi4xODYsMzAuMTI0IEwxOTQuNTY3LDI4Ljc1IEwxNjkuNDQyLDE0LjI0NCBDMTY5LjIxOSwxNC4xMTUgMTY5LjE0MiwxMy44MjkgMTY5LjI3MSwxMy42MDYgQzE2OS40LDEzLjM4MiAxNjkuNjg1LDEzLjMwNiAxNjkuOTA5LDEzLjQzNSBMMTk1LjczNCwyOC4zNDUgQzE5NS44NzksMjguNDI4IDE5NS45NjgsMjguNTgzIDE5NS45NjgsMjguNzUgQzE5NS45NjgsMjguOTE2IDE5NS44NzksMjkuMDcxIDE5NS43MzQsMjkuMTU0IEwxOTIuNjUzLDMwLjkzMyBDMTkxLjkzMiwzMS4zNSAxOTAuODksMzEuNTA4IDE4OS45MzUsMzEuMzQ2IEMxODEuOTcyLDI5Ljk5NSAxNzMuNDc4LDI5Ljg2IDE2NS4zNzIsMzAuOTU0IEMxNTMuNjAyLDMyLjU0MyAxNDIuODYsMzUuOTkzIDEzNC4zMDcsNDAuOTMxIEMxMjUuNzkzLDQ1Ljg0NyAxMTkuODUxLDUyLjAwNCAxMTcuMTI0LDU4LjczNiBDMTE1LjI3LDYzLjMxNCAxMTUuNTAxLDY4LjExMiAxMTcuNzksNzIuNjExIEMxMTguMTYsNzMuMzM2IDExNy44NDUsNzQuMTI0IDExNi45OSw3NC42MTcgTDExMy45MDksNzYuMzk3IEMxMTMuODM2LDc2LjQzOCAxMTMuNzU2LDc2LjQ1OSAxMTMuNjc1LDc2LjQ1OSIgaWQ9IkZpbGwtMjQiIGZpbGw9IiM0NTVBNjQiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTUzLjMxNiwyMS4yNzkgQzE1MC45MDMsMjEuMjc5IDE0OC40OTUsMjAuNzUxIDE0Ni42NjQsMTkuNjkzIEMxNDQuODQ2LDE4LjY0NCAxNDMuODQ0LDE3LjIzMiAxNDMuODQ0LDE1LjcxOCBDMTQzLjg0NCwxNC4xOTEgMTQ0Ljg2LDEyLjc2MyAxNDYuNzA1LDExLjY5OCBMMTU2LjE5OCw2LjA5MSBDMTU2LjMwOSw2LjAyNSAxNTYuNDUyLDYuMDYyIDE1Ni41MTgsNi4xNzMgQzE1Ni41ODMsNi4yODQgMTU2LjU0Nyw2LjQyNyAxNTYuNDM2LDYuNDkzIEwxNDYuOTQsMTIuMTAyIEMxNDUuMjQ0LDEzLjA4MSAxNDQuMzEyLDE0LjM2NSAxNDQuMzEyLDE1LjcxOCBDMTQ0LjMxMiwxNy4wNTggMTQ1LjIzLDE4LjMyNiAxNDYuODk3LDE5LjI4OSBDMTUwLjQ0NiwyMS4zMzggMTU2LjI0LDIxLjMyNyAxNTkuODExLDE5LjI2NSBMMTY5LjU1OSwxMy42MzcgQzE2OS42NywxMy41NzMgMTY5LjgxMywxMy42MTEgMTY5Ljg3OCwxMy43MjMgQzE2OS45NDMsMTMuODM0IDE2OS45MDQsMTMuOTc3IDE2OS43OTMsMTQuMDQyIEwxNjAuMDQ1LDE5LjY3IEMxNTguMTg3LDIwLjc0MiAxNTUuNzQ5LDIxLjI3OSAxNTMuMzE2LDIxLjI3OSIgaWQ9IkZpbGwtMjUiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTEzLjY3NSw3NS45OTIgTDY3Ljc2Miw0OS40ODQiIGlkPSJGaWxsLTI2IiBmaWxsPSIjNDU1QTY0Ij48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTExMy42NzUsNzYuMzQyIEMxMTMuNjE1LDc2LjM0MiAxMTMuNTU1LDc2LjMyNyAxMTMuNSw3Ni4yOTUgTDY3LjU4Nyw0OS43ODcgQzY3LjQxOSw0OS42OSA2Ny4zNjIsNDkuNDc2IDY3LjQ1OSw0OS4zMDkgQzY3LjU1Niw0OS4xNDEgNjcuNzcsNDkuMDgzIDY3LjkzNyw0OS4xOCBMMTEzLjg1LDc1LjY4OCBDMTE0LjAxOCw3NS43ODUgMTE0LjA3NSw3NiAxMTMuOTc4LDc2LjE2NyBDMTEzLjkxNCw3Ni4yNzkgMTEzLjc5Niw3Ni4zNDIgMTEzLjY3NSw3Ni4zNDIiIGlkPSJGaWxsLTI3IiBmaWxsPSIjNDU1QTY0Ij48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTY3Ljc2Miw0OS40ODQgTDY3Ljc2MiwxMDMuNDg1IEM2Ny43NjIsMTA0LjU3NSA2OC41MzIsMTA1LjkwMyA2OS40ODIsMTA2LjQ1MiBMMTExLjk1NSwxMzAuOTczIEMxMTIuOTA1LDEzMS41MjIgMTEzLjY3NSwxMzEuMDgzIDExMy42NzUsMTI5Ljk5MyBMMTEzLjY3NSw3NS45OTIiIGlkPSJGaWxsLTI4IiBmaWxsPSIjRkFGQUZBIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTExMi43MjcsMTMxLjU2MSBDMTEyLjQzLDEzMS41NjEgMTEyLjEwNywxMzEuNDY2IDExMS43OCwxMzEuMjc2IEw2OS4zMDcsMTA2Ljc1NSBDNjguMjQ0LDEwNi4xNDIgNjcuNDEyLDEwNC43MDUgNjcuNDEyLDEwMy40ODUgTDY3LjQxMiw0OS40ODQgQzY3LjQxMiw0OS4yOSA2Ny41NjksNDkuMTM0IDY3Ljc2Miw0OS4xMzQgQzY3Ljk1Niw0OS4xMzQgNjguMTEzLDQ5LjI5IDY4LjExMyw0OS40ODQgTDY4LjExMywxMDMuNDg1IEM2OC4xMTMsMTA0LjQ0NSA2OC44MiwxMDUuNjY1IDY5LjY1NywxMDYuMTQ4IEwxMTIuMTMsMTMwLjY3IEMxMTIuNDc0LDEzMC44NjggMTEyLjc5MSwxMzAuOTEzIDExMywxMzAuNzkyIEMxMTMuMjA2LDEzMC42NzMgMTEzLjMyNSwxMzAuMzgxIDExMy4zMjUsMTI5Ljk5MyBMMTEzLjMyNSw3NS45OTIgQzExMy4zMjUsNzUuNzk4IDExMy40ODIsNzUuNjQxIDExMy42NzUsNzUuNjQxIEMxMTMuODY5LDc1LjY0MSAxMTQuMDI1LDc1Ljc5OCAxMTQuMDI1LDc1Ljk5MiBMMTE0LjAyNSwxMjkuOTkzIEMxMTQuMDI1LDEzMC42NDggMTEzLjc4NiwxMzEuMTQ3IDExMy4zNSwxMzEuMzk5IEMxMTMuMTYyLDEzMS41MDcgMTEyLjk1MiwxMzEuNTYxIDExMi43MjcsMTMxLjU2MSIgaWQ9IkZpbGwtMjkiIGZpbGw9IiM0NTVBNjQiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTEyLjg2LDQwLjUxMiBDMTEyLjg2LDQwLjUxMiAxMTIuODYsNDAuNTEyIDExMi44NTksNDAuNTEyIEMxMTAuNTQxLDQwLjUxMiAxMDguMzYsMzkuOTkgMTA2LjcxNywzOS4wNDEgQzEwNS4wMTIsMzguMDU3IDEwNC4wNzQsMzYuNzI2IDEwNC4wNzQsMzUuMjkyIEMxMDQuMDc0LDMzLjg0NyAxMDUuMDI2LDMyLjUwMSAxMDYuNzU0LDMxLjUwNCBMMTE4Ljc5NSwyNC41NTEgQzEyMC40NjMsMjMuNTg5IDEyMi42NjksMjMuMDU4IDEyNS4wMDcsMjMuMDU4IEMxMjcuMzI1LDIzLjA1OCAxMjkuNTA2LDIzLjU4MSAxMzEuMTUsMjQuNTMgQzEzMi44NTQsMjUuNTE0IDEzMy43OTMsMjYuODQ1IDEzMy43OTMsMjguMjc4IEMxMzMuNzkzLDI5LjcyNCAxMzIuODQxLDMxLjA2OSAxMzEuMTEzLDMyLjA2NyBMMTE5LjA3MSwzOS4wMTkgQzExNy40MDMsMzkuOTgyIDExNS4xOTcsNDAuNTEyIDExMi44Niw0MC41MTIgTDExMi44Niw0MC41MTIgWiBNMTI1LjAwNywyMy43NTkgQzEyMi43OSwyMy43NTkgMTIwLjcwOSwyNC4yNTYgMTE5LjE0NiwyNS4xNTggTDEwNy4xMDQsMzIuMTEgQzEwNS42MDIsMzIuOTc4IDEwNC43NzQsMzQuMTA4IDEwNC43NzQsMzUuMjkyIEMxMDQuNzc0LDM2LjQ2NSAxMDUuNTg5LDM3LjU4MSAxMDcuMDY3LDM4LjQzNCBDMTA4LjYwNSwzOS4zMjMgMTEwLjY2MywzOS44MTIgMTEyLjg1OSwzOS44MTIgTDExMi44NiwzOS44MTIgQzExNS4wNzYsMzkuODEyIDExNy4xNTgsMzkuMzE1IDExOC43MjEsMzguNDEzIEwxMzAuNzYyLDMxLjQ2IEMxMzIuMjY0LDMwLjU5MyAxMzMuMDkyLDI5LjQ2MyAxMzMuMDkyLDI4LjI3OCBDMTMzLjA5MiwyNy4xMDYgMTMyLjI3OCwyNS45OSAxMzAuOCwyNS4xMzYgQzEyOS4yNjEsMjQuMjQ4IDEyNy4yMDQsMjMuNzU5IDEyNS4wMDcsMjMuNzU5IEwxMjUuMDA3LDIzLjc1OSBaIiBpZD0iRmlsbC0zMCIgZmlsbD0iIzYwN0Q4QiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNjUuNjMsMTYuMjE5IEwxNTkuODk2LDE5LjUzIEMxNTYuNzI5LDIxLjM1OCAxNTEuNjEsMjEuMzY3IDE0OC40NjMsMTkuNTUgQzE0NS4zMTYsMTcuNzMzIDE0NS4zMzIsMTQuNzc4IDE0OC40OTksMTIuOTQ5IEwxNTQuMjMzLDkuNjM5IEwxNjUuNjMsMTYuMjE5IiBpZD0iRmlsbC0zMSIgZmlsbD0iI0ZBRkFGQSI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xNTQuMjMzLDEwLjQ0OCBMMTY0LjIyOCwxNi4yMTkgTDE1OS41NDYsMTguOTIzIEMxNTguMTEyLDE5Ljc1IDE1Ni4xOTQsMjAuMjA2IDE1NC4xNDcsMjAuMjA2IEMxNTIuMTE4LDIwLjIwNiAxNTAuMjI0LDE5Ljc1NyAxNDguODE0LDE4Ljk0MyBDMTQ3LjUyNCwxOC4xOTkgMTQ2LjgxNCwxNy4yNDkgMTQ2LjgxNCwxNi4yNjkgQzE0Ni44MTQsMTUuMjc4IDE0Ny41MzcsMTQuMzE0IDE0OC44NSwxMy41NTYgTDE1NC4yMzMsMTAuNDQ4IE0xNTQuMjMzLDkuNjM5IEwxNDguNDk5LDEyLjk0OSBDMTQ1LjMzMiwxNC43NzggMTQ1LjMxNiwxNy43MzMgMTQ4LjQ2MywxOS41NSBDMTUwLjAzMSwyMC40NTUgMTUyLjA4NiwyMC45MDcgMTU0LjE0NywyMC45MDcgQzE1Ni4yMjQsMjAuOTA3IDE1OC4zMDYsMjAuNDQ3IDE1OS44OTYsMTkuNTMgTDE2NS42MywxNi4yMTkgTDE1NC4yMzMsOS42MzkiIGlkPSJGaWxsLTMyIiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE0NS40NDUsNzIuNjY3IEwxNDUuNDQ1LDcyLjY2NyBDMTQzLjY3Miw3Mi42NjcgMTQyLjIwNCw3MS44MTcgMTQxLjIwMiw3MC40MjIgQzE0MS4xMzUsNzAuMzMgMTQxLjE0NSw3MC4xNDcgMTQxLjIyNSw3MC4wNjYgQzE0MS4zMDUsNjkuOTg1IDE0MS40MzIsNjkuOTQ2IDE0MS41MjUsNzAuMDExIEMxNDIuMzA2LDcwLjU1OSAxNDMuMjMxLDcwLjgyMyAxNDQuMjc2LDcwLjgyMiBDMTQ1LjU5OCw3MC44MjIgMTQ3LjAzLDcwLjM3NiAxNDguNTMyLDY5LjUwOSBDMTUzLjg0Miw2Ni40NDMgMTU4LjE2Myw1OC45ODcgMTU4LjE2Myw1Mi44OTQgQzE1OC4xNjMsNTAuOTY3IDE1Ny43MjEsNDkuMzMyIDE1Ni44ODQsNDguMTY4IEMxNTYuODE4LDQ4LjA3NiAxNTYuODI4LDQ3Ljk0OCAxNTYuOTA4LDQ3Ljg2NyBDMTU2Ljk4OCw0Ny43ODYgMTU3LjExNCw0Ny43NzQgMTU3LjIwOCw0Ny44NCBDMTU4Ljg3OCw0OS4wMTIgMTU5Ljc5OCw1MS4yMiAxNTkuNzk4LDU0LjA1OSBDMTU5Ljc5OCw2MC4zMDEgMTU1LjM3Myw2OC4wNDYgMTQ5LjkzMyw3MS4xODYgQzE0OC4zNiw3Mi4wOTQgMTQ2Ljg1LDcyLjY2NyAxNDUuNDQ1LDcyLjY2NyBMMTQ1LjQ0NSw3Mi42NjcgWiBNMTQyLjQ3Niw3MSBDMTQzLjI5LDcxLjY1MSAxNDQuMjk2LDcyLjAwMiAxNDUuNDQ1LDcyLjAwMiBDMTQ2Ljc2Nyw3Mi4wMDIgMTQ4LjE5OCw3MS41NSAxNDkuNyw3MC42ODIgQzE1NS4wMSw2Ny42MTcgMTU5LjMzMSw2MC4xNTkgMTU5LjMzMSw1NC4wNjUgQzE1OS4zMzEsNTIuMDg1IDE1OC44NjgsNTAuNDM1IDE1OC4wMDYsNDkuMjcyIEMxNTguNDE3LDUwLjMwNyAxNTguNjMsNTEuNTMyIDE1OC42Myw1Mi44OTIgQzE1OC42Myw1OS4xMzQgMTU0LjIwNSw2Ni43NjcgMTQ4Ljc2NSw2OS45MDcgQzE0Ny4xOTIsNzAuODE2IDE0NS42ODEsNzEuMjgzIDE0NC4yNzYsNzEuMjgzIEMxNDMuNjM0LDcxLjI4MyAxNDMuMDMzLDcxLjE5MiAxNDIuNDc2LDcxIEwxNDIuNDc2LDcxIFoiIGlkPSJGaWxsLTMzIiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE0OC42NDgsNjkuNzA0IEMxNTQuMDMyLDY2LjU5NiAxNTguMzk2LDU5LjA2OCAxNTguMzk2LDUyLjg5MSBDMTU4LjM5Niw1MC44MzkgMTU3LjkxMyw0OS4xOTggMTU3LjA3NCw0OC4wMyBDMTU1LjI4OSw0Ni43NzggMTUyLjY5OSw0Ni44MzYgMTQ5LjgxNiw0OC41MDEgQzE0NC40MzMsNTEuNjA5IDE0MC4wNjgsNTkuMTM3IDE0MC4wNjgsNjUuMzE0IEMxNDAuMDY4LDY3LjM2NSAxNDAuNTUyLDY5LjAwNiAxNDEuMzkxLDcwLjE3NCBDMTQzLjE3Niw3MS40MjcgMTQ1Ljc2NSw3MS4zNjkgMTQ4LjY0OCw2OS43MDQiIGlkPSJGaWxsLTM0IiBmaWxsPSIjRkFGQUZBIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTE0NC4yNzYsNzEuMjc2IEwxNDQuMjc2LDcxLjI3NiBDMTQzLjEzMyw3MS4yNzYgMTQyLjExOCw3MC45NjkgMTQxLjI1Nyw3MC4zNjUgQzE0MS4yMzYsNzAuMzUxIDE0MS4yMTcsNzAuMzMyIDE0MS4yMDIsNzAuMzExIEMxNDAuMzA3LDY5LjA2NyAxMzkuODM1LDY3LjMzOSAxMzkuODM1LDY1LjMxNCBDMTM5LjgzNSw1OS4wNzMgMTQ0LjI2LDUxLjQzOSAxNDkuNyw0OC4yOTggQzE1MS4yNzMsNDcuMzkgMTUyLjc4NCw0Ni45MjkgMTU0LjE4OSw0Ni45MjkgQzE1NS4zMzIsNDYuOTI5IDE1Ni4zNDcsNDcuMjM2IDE1Ny4yMDgsNDcuODM5IEMxNTcuMjI5LDQ3Ljg1NCAxNTcuMjQ4LDQ3Ljg3MyAxNTcuMjYzLDQ3Ljg5NCBDMTU4LjE1Nyw0OS4xMzggMTU4LjYzLDUwLjg2NSAxNTguNjMsNTIuODkxIEMxNTguNjMsNTkuMTMyIDE1NC4yMDUsNjYuNzY2IDE0OC43NjUsNjkuOTA3IEMxNDcuMTkyLDcwLjgxNSAxNDUuNjgxLDcxLjI3NiAxNDQuMjc2LDcxLjI3NiBMMTQ0LjI3Niw3MS4yNzYgWiBNMTQxLjU1OCw3MC4xMDQgQzE0Mi4zMzEsNzAuNjM3IDE0My4yNDUsNzEuMDA1IDE0NC4yNzYsNzEuMDA1IEMxNDUuNTk4LDcxLjAwNSAxNDcuMDMsNzAuNDY3IDE0OC41MzIsNjkuNiBDMTUzLjg0Miw2Ni41MzQgMTU4LjE2Myw1OS4wMzMgMTU4LjE2Myw1Mi45MzkgQzE1OC4xNjMsNTEuMDMxIDE1Ny43MjksNDkuMzg1IDE1Ni45MDcsNDguMjIzIEMxNTYuMTMzLDQ3LjY5MSAxNTUuMjE5LDQ3LjQwOSAxNTQuMTg5LDQ3LjQwOSBDMTUyLjg2Nyw0Ny40MDkgMTUxLjQzNSw0Ny44NDIgMTQ5LjkzMyw0OC43MDkgQzE0NC42MjMsNTEuNzc1IDE0MC4zMDIsNTkuMjczIDE0MC4zMDIsNjUuMzY2IEMxNDAuMzAyLDY3LjI3NiAxNDAuNzM2LDY4Ljk0MiAxNDEuNTU4LDcwLjEwNCBMMTQxLjU1OCw3MC4xMDQgWiIgaWQ9IkZpbGwtMzUiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTUwLjcyLDY1LjM2MSBMMTUwLjM1Nyw2NS4wNjYgQzE1MS4xNDcsNjQuMDkyIDE1MS44NjksNjMuMDQgMTUyLjUwNSw2MS45MzggQzE1My4zMTMsNjAuNTM5IDE1My45NzgsNTkuMDY3IDE1NC40ODIsNTcuNTYzIEwxNTQuOTI1LDU3LjcxMiBDMTU0LjQxMiw1OS4yNDUgMTUzLjczMyw2MC43NDUgMTUyLjkxLDYyLjE3MiBDMTUyLjI2Miw2My4yOTUgMTUxLjUyNSw2NC4zNjggMTUwLjcyLDY1LjM2MSIgaWQ9IkZpbGwtMzYiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTE1LjkxNyw4NC41MTQgTDExNS41NTQsODQuMjIgQzExNi4zNDQsODMuMjQ1IDExNy4wNjYsODIuMTk0IDExNy43MDIsODEuMDkyIEMxMTguNTEsNzkuNjkyIDExOS4xNzUsNzguMjIgMTE5LjY3OCw3Ni43MTcgTDEyMC4xMjEsNzYuODY1IEMxMTkuNjA4LDc4LjM5OCAxMTguOTMsNzkuODk5IDExOC4xMDYsODEuMzI2IEMxMTcuNDU4LDgyLjQ0OCAxMTYuNzIyLDgzLjUyMSAxMTUuOTE3LDg0LjUxNCIgaWQ9IkZpbGwtMzciIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTE0LDEzMC40NzYgTDExNCwxMzAuMDA4IEwxMTQsNzYuMDUyIEwxMTQsNzUuNTg0IEwxMTQsNzYuMDUyIEwxMTQsMTMwLjAwOCBMMTE0LDEzMC40NzYiIGlkPSJGaWxsLTM4IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICA8L2c+CiAgICAgICAgICAgICAgICA8ZyBpZD0iSW1wb3J0ZWQtTGF5ZXJzLUNvcHkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDYyLjAwMDAwMCwgMC4wMDAwMDApIiBza2V0Y2g6dHlwZT0iTVNTaGFwZUdyb3VwIj4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTkuODIyLDM3LjQ3NCBDMTkuODM5LDM3LjMzOSAxOS43NDcsMzcuMTk0IDE5LjU1NSwzNy4wODIgQzE5LjIyOCwzNi44OTQgMTguNzI5LDM2Ljg3MiAxOC40NDYsMzcuMDM3IEwxMi40MzQsNDAuNTA4IEMxMi4zMDMsNDAuNTg0IDEyLjI0LDQwLjY4NiAxMi4yNDMsNDAuNzkzIEMxMi4yNDUsNDAuOTI1IDEyLjI0NSw0MS4yNTQgMTIuMjQ1LDQxLjM3MSBMMTIuMjQ1LDQxLjQxNCBMMTIuMjM4LDQxLjU0MiBDOC4xNDgsNDMuODg3IDUuNjQ3LDQ1LjMyMSA1LjY0Nyw0NS4zMjEgQzUuNjQ2LDQ1LjMyMSAzLjU3LDQ2LjM2NyAyLjg2LDUwLjUxMyBDMi44Niw1MC41MTMgMS45NDgsNTcuNDc0IDEuOTYyLDcwLjI1OCBDMS45NzcsODIuODI4IDIuNTY4LDg3LjMyOCAzLjEyOSw5MS42MDkgQzMuMzQ5LDkzLjI5MyA2LjEzLDkzLjczNCA2LjEzLDkzLjczNCBDNi40NjEsOTMuNzc0IDYuODI4LDkzLjcwNyA3LjIxLDkzLjQ4NiBMODIuNDgzLDQ5LjkzNSBDODQuMjkxLDQ4Ljg2NiA4NS4xNSw0Ni4yMTYgODUuNTM5LDQzLjY1MSBDODYuNzUyLDM1LjY2MSA4Ny4yMTQsMTAuNjczIDg1LjI2NCwzLjc3MyBDODUuMDY4LDMuMDggODQuNzU0LDIuNjkgODQuMzk2LDIuNDkxIEw4Mi4zMSwxLjcwMSBDODEuNTgzLDEuNzI5IDgwLjg5NCwyLjE2OCA4MC43NzYsMi4yMzYgQzgwLjYzNiwyLjMxNyA0MS44MDcsMjQuNTg1IDIwLjAzMiwzNy4wNzIgTDE5LjgyMiwzNy40NzQiIGlkPSJGaWxsLTEiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNODIuMzExLDEuNzAxIEw4NC4zOTYsMi40OTEgQzg0Ljc1NCwyLjY5IDg1LjA2OCwzLjA4IDg1LjI2NCwzLjc3MyBDODcuMjEzLDEwLjY3MyA4Ni43NTEsMzUuNjYgODUuNTM5LDQzLjY1MSBDODUuMTQ5LDQ2LjIxNiA4NC4yOSw0OC44NjYgODIuNDgzLDQ5LjkzNSBMNy4yMSw5My40ODYgQzYuODk3LDkzLjY2NyA2LjU5NSw5My43NDQgNi4zMTQsOTMuNzQ0IEw2LjEzMSw5My43MzMgQzYuMTMxLDkzLjczNCAzLjM0OSw5My4yOTMgMy4xMjgsOTEuNjA5IEMyLjU2OCw4Ny4zMjcgMS45NzcsODIuODI4IDEuOTYzLDcwLjI1OCBDMS45NDgsNTcuNDc0IDIuODYsNTAuNTEzIDIuODYsNTAuNTEzIEMzLjU3LDQ2LjM2NyA1LjY0Nyw0NS4zMjEgNS42NDcsNDUuMzIxIEM1LjY0Nyw0NS4zMjEgOC4xNDgsNDMuODg3IDEyLjIzOCw0MS41NDIgTDEyLjI0NSw0MS40MTQgTDEyLjI0NSw0MS4zNzEgQzEyLjI0NSw0MS4yNTQgMTIuMjQ1LDQwLjkyNSAxMi4yNDMsNDAuNzkzIEMxMi4yNCw0MC42ODYgMTIuMzAyLDQwLjU4MyAxMi40MzQsNDAuNTA4IEwxOC40NDYsMzcuMDM2IEMxOC41NzQsMzYuOTYyIDE4Ljc0NiwzNi45MjYgMTguOTI3LDM2LjkyNiBDMTkuMTQ1LDM2LjkyNiAxOS4zNzYsMzYuOTc5IDE5LjU1NCwzNy4wODIgQzE5Ljc0NywzNy4xOTQgMTkuODM5LDM3LjM0IDE5LjgyMiwzNy40NzQgTDIwLjAzMywzNy4wNzIgQzQxLjgwNiwyNC41ODUgODAuNjM2LDIuMzE4IDgwLjc3NywyLjIzNiBDODAuODk0LDIuMTY4IDgxLjU4MywxLjcyOSA4Mi4zMTEsMS43MDEgTTgyLjMxMSwwLjcwNCBMODIuMjcyLDAuNzA1IEM4MS42NTQsMC43MjggODAuOTg5LDAuOTQ5IDgwLjI5OCwxLjM2MSBMODAuMjc3LDEuMzczIEM4MC4xMjksMS40NTggNTkuNzY4LDEzLjEzNSAxOS43NTgsMzYuMDc5IEMxOS41LDM1Ljk4MSAxOS4yMTQsMzUuOTI5IDE4LjkyNywzNS45MjkgQzE4LjU2MiwzNS45MjkgMTguMjIzLDM2LjAxMyAxNy45NDcsMzYuMTczIEwxMS45MzUsMzkuNjQ0IEMxMS40OTMsMzkuODk5IDExLjIzNiw0MC4zMzQgMTEuMjQ2LDQwLjgxIEwxMS4yNDcsNDAuOTYgTDUuMTY3LDQ0LjQ0NyBDNC43OTQsNDQuNjQ2IDIuNjI1LDQ1Ljk3OCAxLjg3Nyw1MC4zNDUgTDEuODcxLDUwLjM4NCBDMS44NjIsNTAuNDU0IDAuOTUxLDU3LjU1NyAwLjk2NSw3MC4yNTkgQzAuOTc5LDgyLjg3OSAxLjU2OCw4Ny4zNzUgMi4xMzcsOTEuNzI0IEwyLjEzOSw5MS43MzkgQzIuNDQ3LDk0LjA5NCA1LjYxNCw5NC42NjIgNS45NzUsOTQuNzE5IEw2LjAwOSw5NC43MjMgQzYuMTEsOTQuNzM2IDYuMjEzLDk0Ljc0MiA2LjMxNCw5NC43NDIgQzYuNzksOTQuNzQyIDcuMjYsOTQuNjEgNy43MSw5NC4zNSBMODIuOTgzLDUwLjc5OCBDODQuNzk0LDQ5LjcyNyA4NS45ODIsNDcuMzc1IDg2LjUyNSw0My44MDEgQzg3LjcxMSwzNS45ODcgODguMjU5LDEwLjcwNSA4Ni4yMjQsMy41MDIgQzg1Ljk3MSwyLjYwOSA4NS41MiwxLjk3NSA4NC44ODEsMS42MiBMODQuNzQ5LDEuNTU4IEw4Mi42NjQsMC43NjkgQzgyLjU1MSwwLjcyNSA4Mi40MzEsMC43MDQgODIuMzExLDAuNzA0IiBpZD0iRmlsbC0yIiBmaWxsPSIjNDU1QTY0Ij48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTY2LjI2NywxMS41NjUgTDY3Ljc2MiwxMS45OTkgTDExLjQyMyw0NC4zMjUiIGlkPSJGaWxsLTMiIGZpbGw9IiNGRkZGRkYiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTIuMjAyLDkwLjU0NSBDMTIuMDI5LDkwLjU0NSAxMS44NjIsOTAuNDU1IDExLjc2OSw5MC4yOTUgQzExLjYzMiw5MC4wNTcgMTEuNzEzLDg5Ljc1MiAxMS45NTIsODkuNjE0IEwzMC4zODksNzguOTY5IEMzMC42MjgsNzguODMxIDMwLjkzMyw3OC45MTMgMzEuMDcxLDc5LjE1MiBDMzEuMjA4LDc5LjM5IDMxLjEyNyw3OS42OTYgMzAuODg4LDc5LjgzMyBMMTIuNDUxLDkwLjQ3OCBMMTIuMjAyLDkwLjU0NSIgaWQ9IkZpbGwtNCIgZmlsbD0iIzYwN0Q4QiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMy43NjQsNDIuNjU0IEwxMy42NTYsNDIuNTkyIEwxMy43MDIsNDIuNDIxIEwxOC44MzcsMzkuNDU3IEwxOS4wMDcsMzkuNTAyIEwxOC45NjIsMzkuNjczIEwxMy44MjcsNDIuNjM3IEwxMy43NjQsNDIuNjU0IiBpZD0iRmlsbC01IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTguNTIsOTAuMzc1IEw4LjUyLDQ2LjQyMSBMOC41ODMsNDYuMzg1IEw3NS44NCw3LjU1NCBMNzUuODQsNTEuNTA4IEw3NS43NzgsNTEuNTQ0IEw4LjUyLDkwLjM3NSBMOC41Miw5MC4zNzUgWiBNOC43Nyw0Ni41NjQgTDguNzcsODkuOTQ0IEw3NS41OTEsNTEuMzY1IEw3NS41OTEsNy45ODUgTDguNzcsNDYuNTY0IEw4Ljc3LDQ2LjU2NCBaIiBpZD0iRmlsbC02IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTI0Ljk4Niw4My4xODIgQzI0Ljc1Niw4My4zMzEgMjQuMzc0LDgzLjU2NiAyNC4xMzcsODMuNzA1IEwxMi42MzIsOTAuNDA2IEMxMi4zOTUsOTAuNTQ1IDEyLjQyNiw5MC42NTggMTIuNyw5MC42NTggTDEzLjI2NSw5MC42NTggQzEzLjU0LDkwLjY1OCAxMy45NTgsOTAuNTQ1IDE0LjE5NSw5MC40MDYgTDI1LjcsODMuNzA1IEMyNS45MzcsODMuNTY2IDI2LjEyOCw4My40NTIgMjYuMTI1LDgzLjQ0OSBDMjYuMTIyLDgzLjQ0NyAyNi4xMTksODMuMjIgMjYuMTE5LDgyLjk0NiBDMjYuMTE5LDgyLjY3MiAyNS45MzEsODIuNTY5IDI1LjcwMSw4Mi43MTkgTDI0Ljk4Niw4My4xODIiIGlkPSJGaWxsLTciIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTMuMjY2LDkwLjc4MiBMMTIuNyw5MC43ODIgQzEyLjUsOTAuNzgyIDEyLjM4NCw5MC43MjYgMTIuMzU0LDkwLjYxNiBDMTIuMzI0LDkwLjUwNiAxMi4zOTcsOTAuMzk5IDEyLjU2OSw5MC4yOTkgTDI0LjA3NCw4My41OTcgQzI0LjMxLDgzLjQ1OSAyNC42ODksODMuMjI2IDI0LjkxOCw4My4wNzggTDI1LjYzMyw4Mi42MTQgQzI1LjcyMyw4Mi41NTUgMjUuODEzLDgyLjUyNSAyNS44OTksODIuNTI1IEMyNi4wNzEsODIuNTI1IDI2LjI0NCw4Mi42NTUgMjYuMjQ0LDgyLjk0NiBDMjYuMjQ0LDgzLjE2IDI2LjI0NSw4My4zMDkgMjYuMjQ3LDgzLjM4MyBMMjYuMjUzLDgzLjM4NyBMMjYuMjQ5LDgzLjQ1NiBDMjYuMjQ2LDgzLjUzMSAyNi4yNDYsODMuNTMxIDI1Ljc2Myw4My44MTIgTDE0LjI1OCw5MC41MTQgQzE0LDkwLjY2NSAxMy41NjQsOTAuNzgyIDEzLjI2Niw5MC43ODIgTDEzLjI2Niw5MC43ODIgWiBNMTIuNjY2LDkwLjUzMiBMMTIuNyw5MC41MzMgTDEzLjI2Niw5MC41MzMgQzEzLjUxOCw5MC41MzMgMTMuOTE1LDkwLjQyNSAxNC4xMzIsOTAuMjk5IEwyNS42MzcsODMuNTk3IEMyNS44MDUsODMuNDk5IDI1LjkzMSw4My40MjQgMjUuOTk4LDgzLjM4MyBDMjUuOTk0LDgzLjI5OSAyNS45OTQsODMuMTY1IDI1Ljk5NCw4Mi45NDYgTDI1Ljg5OSw4Mi43NzUgTDI1Ljc2OCw4Mi44MjQgTDI1LjA1NCw4My4yODcgQzI0LjgyMiw4My40MzcgMjQuNDM4LDgzLjY3MyAyNC4yLDgzLjgxMiBMMTIuNjk1LDkwLjUxNCBMMTIuNjY2LDkwLjUzMiBMMTIuNjY2LDkwLjUzMiBaIiBpZD0iRmlsbC04IiBmaWxsPSIjNjA3RDhCIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEzLjI2Niw4OS44NzEgTDEyLjcsODkuODcxIEMxMi41LDg5Ljg3MSAxMi4zODQsODkuODE1IDEyLjM1NCw4OS43MDUgQzEyLjMyNCw4OS41OTUgMTIuMzk3LDg5LjQ4OCAxMi41NjksODkuMzg4IEwyNC4wNzQsODIuNjg2IEMyNC4zMzIsODIuNTM1IDI0Ljc2OCw4Mi40MTggMjUuMDY3LDgyLjQxOCBMMjUuNjMyLDgyLjQxOCBDMjUuODMyLDgyLjQxOCAyNS45NDgsODIuNDc0IDI1Ljk3OCw4Mi41ODQgQzI2LjAwOCw4Mi42OTQgMjUuOTM1LDgyLjgwMSAyNS43NjMsODIuOTAxIEwxNC4yNTgsODkuNjAzIEMxNCw4OS43NTQgMTMuNTY0LDg5Ljg3MSAxMy4yNjYsODkuODcxIEwxMy4yNjYsODkuODcxIFogTTEyLjY2Niw4OS42MjEgTDEyLjcsODkuNjIyIEwxMy4yNjYsODkuNjIyIEMxMy41MTgsODkuNjIyIDEzLjkxNSw4OS41MTUgMTQuMTMyLDg5LjM4OCBMMjUuNjM3LDgyLjY4NiBMMjUuNjY3LDgyLjY2OCBMMjUuNjMyLDgyLjY2NyBMMjUuMDY3LDgyLjY2NyBDMjQuODE1LDgyLjY2NyAyNC40MTgsODIuNzc1IDI0LjIsODIuOTAxIEwxMi42OTUsODkuNjAzIEwxMi42NjYsODkuNjIxIEwxMi42NjYsODkuNjIxIFoiIGlkPSJGaWxsLTkiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNMTIuMzcsOTAuODAxIEwxMi4zNyw4OS41NTQgTDEyLjM3LDkwLjgwMSIgaWQ9IkZpbGwtMTAiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNi4xMyw5My45MDEgQzUuMzc5LDkzLjgwOCA0LjgxNiw5My4xNjQgNC42OTEsOTIuNTI1IEMzLjg2LDg4LjI4NyAzLjU0LDgzLjc0MyAzLjUyNiw3MS4xNzMgQzMuNTExLDU4LjM4OSA0LjQyMyw1MS40MjggNC40MjMsNTEuNDI4IEM1LjEzNCw0Ny4yODIgNy4yMSw0Ni4yMzYgNy4yMSw0Ni4yMzYgQzcuMjEsNDYuMjM2IDgxLjY2NywzLjI1IDgyLjA2OSwzLjAxNyBDODIuMjkyLDIuODg4IDg0LjU1NiwxLjQzMyA4NS4yNjQsMy45NCBDODcuMjE0LDEwLjg0IDg2Ljc1MiwzNS44MjcgODUuNTM5LDQzLjgxOCBDODUuMTUsNDYuMzgzIDg0LjI5MSw0OS4wMzMgODIuNDgzLDUwLjEwMSBMNy4yMSw5My42NTMgQzYuODI4LDkzLjg3NCA2LjQ2MSw5My45NDEgNi4xMyw5My45MDEgQzYuMTMsOTMuOTAxIDMuMzQ5LDkzLjQ2IDMuMTI5LDkxLjc3NiBDMi41NjgsODcuNDk1IDEuOTc3LDgyLjk5NSAxLjk2Miw3MC40MjUgQzEuOTQ4LDU3LjY0MSAyLjg2LDUwLjY4IDIuODYsNTAuNjggQzMuNTcsNDYuNTM0IDUuNjQ3LDQ1LjQ4OSA1LjY0Nyw0NS40ODkgQzUuNjQ2LDQ1LjQ4OSA4LjA2NSw0NC4wOTIgMTIuMjQ1LDQxLjY3OSBMMTMuMTE2LDQxLjU2IEwxOS43MTUsMzcuNzMgTDE5Ljc2MSwzNy4yNjkgTDYuMTMsOTMuOTAxIiBpZD0iRmlsbC0xMSIgZmlsbD0iI0ZBRkFGQSI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik02LjMxNyw5NC4xNjEgTDYuMTAyLDk0LjE0OCBMNi4xMDEsOTQuMTQ4IEw1Ljg1Nyw5NC4xMDEgQzUuMTM4LDkzLjk0NSAzLjA4NSw5My4zNjUgMi44ODEsOTEuODA5IEMyLjMxMyw4Ny40NjkgMS43MjcsODIuOTk2IDEuNzEzLDcwLjQyNSBDMS42OTksNTcuNzcxIDIuNjA0LDUwLjcxOCAyLjYxMyw1MC42NDggQzMuMzM4LDQ2LjQxNyA1LjQ0NSw0NS4zMSA1LjUzNSw0NS4yNjYgTDEyLjE2Myw0MS40MzkgTDEzLjAzMyw0MS4zMiBMMTkuNDc5LDM3LjU3OCBMMTkuNTEzLDM3LjI0NCBDMTkuNTI2LDM3LjEwNyAxOS42NDcsMzcuMDA4IDE5Ljc4NiwzNy4wMjEgQzE5LjkyMiwzNy4wMzQgMjAuMDIzLDM3LjE1NiAyMC4wMDksMzcuMjkzIEwxOS45NSwzNy44ODIgTDEzLjE5OCw0MS44MDEgTDEyLjMyOCw0MS45MTkgTDUuNzcyLDQ1LjcwNCBDNS43NDEsNDUuNzIgMy43ODIsNDYuNzcyIDMuMTA2LDUwLjcyMiBDMy4wOTksNTAuNzgyIDIuMTk4LDU3LjgwOCAyLjIxMiw3MC40MjQgQzIuMjI2LDgyLjk2MyAyLjgwOSw4Ny40MiAzLjM3Myw5MS43MjkgQzMuNDY0LDkyLjQyIDQuMDYyLDkyLjg4MyA0LjY4Miw5My4xODEgQzQuNTY2LDkyLjk4NCA0LjQ4Niw5Mi43NzYgNC40NDYsOTIuNTcyIEMzLjY2NSw4OC41ODggMy4yOTEsODQuMzcgMy4yNzYsNzEuMTczIEMzLjI2Miw1OC41MiA0LjE2Nyw1MS40NjYgNC4xNzYsNTEuMzk2IEM0LjkwMSw0Ny4xNjUgNy4wMDgsNDYuMDU5IDcuMDk4LDQ2LjAxNCBDNy4wOTQsNDYuMDE1IDgxLjU0MiwzLjAzNCA4MS45NDQsMi44MDIgTDgxLjk3MiwyLjc4NSBDODIuODc2LDIuMjQ3IDgzLjY5MiwyLjA5NyA4NC4zMzIsMi4zNTIgQzg0Ljg4NywyLjU3MyA4NS4yODEsMy4wODUgODUuNTA0LDMuODcyIEM4Ny41MTgsMTEgODYuOTY0LDM2LjA5MSA4NS43ODUsNDMuODU1IEM4NS4yNzgsNDcuMTk2IDg0LjIxLDQ5LjM3IDgyLjYxLDUwLjMxNyBMNy4zMzUsOTMuODY5IEM2Ljk5OSw5NC4wNjMgNi42NTgsOTQuMTYxIDYuMzE3LDk0LjE2MSBMNi4zMTcsOTQuMTYxIFogTTYuMTcsOTMuNjU0IEM2LjQ2Myw5My42OSA2Ljc3NCw5My42MTcgNy4wODUsOTMuNDM3IEw4Mi4zNTgsNDkuODg2IEM4NC4xODEsNDguODA4IDg0Ljk2LDQ1Ljk3MSA4NS4yOTIsNDMuNzggQzg2LjQ2NiwzNi4wNDkgODcuMDIzLDExLjA4NSA4NS4wMjQsNC4wMDggQzg0Ljg0NiwzLjM3NyA4NC41NTEsMi45NzYgODQuMTQ4LDIuODE2IEM4My42NjQsMi42MjMgODIuOTgyLDIuNzY0IDgyLjIyNywzLjIxMyBMODIuMTkzLDMuMjM0IEM4MS43OTEsMy40NjYgNy4zMzUsNDYuNDUyIDcuMzM1LDQ2LjQ1MiBDNy4zMDQsNDYuNDY5IDUuMzQ2LDQ3LjUyMSA0LjY2OSw1MS40NzEgQzQuNjYyLDUxLjUzIDMuNzYxLDU4LjU1NiAzLjc3NSw3MS4xNzMgQzMuNzksODQuMzI4IDQuMTYxLDg4LjUyNCA0LjkzNiw5Mi40NzYgQzUuMDI2LDkyLjkzNyA1LjQxMiw5My40NTkgNS45NzMsOTMuNjE1IEM2LjA4Nyw5My42NCA2LjE1OCw5My42NTIgNi4xNjksOTMuNjU0IEw2LjE3LDkzLjY1NCBMNi4xNyw5My42NTQgWiIgaWQ9IkZpbGwtMTIiIGZpbGw9IiM0NTVBNjQiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNy4zMTcsNjguOTgyIEM3LjgwNiw2OC43MDEgOC4yMDIsNjguOTI2IDguMjAyLDY5LjQ4NyBDOC4yMDIsNzAuMDQ3IDcuODA2LDcwLjczIDcuMzE3LDcxLjAxMiBDNi44MjksNzEuMjk0IDYuNDMzLDcxLjA2OSA2LjQzMyw3MC41MDggQzYuNDMzLDY5Ljk0OCA2LjgyOSw2OS4yNjUgNy4zMTcsNjguOTgyIiBpZD0iRmlsbC0xMyIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik02LjkyLDcxLjEzMyBDNi42MzEsNzEuMTMzIDYuNDMzLDcwLjkwNSA2LjQzMyw3MC41MDggQzYuNDMzLDY5Ljk0OCA2LjgyOSw2OS4yNjUgNy4zMTcsNjguOTgyIEM3LjQ2LDY4LjkgNy41OTUsNjguODYxIDcuNzE0LDY4Ljg2MSBDOC4wMDMsNjguODYxIDguMjAyLDY5LjA5IDguMjAyLDY5LjQ4NyBDOC4yMDIsNzAuMDQ3IDcuODA2LDcwLjczIDcuMzE3LDcxLjAxMiBDNy4xNzQsNzEuMDk0IDcuMDM5LDcxLjEzMyA2LjkyLDcxLjEzMyBNNy43MTQsNjguNjc0IEM3LjU1Nyw2OC42NzQgNy4zOTIsNjguNzIzIDcuMjI0LDY4LjgyMSBDNi42NzYsNjkuMTM4IDYuMjQ2LDY5Ljg3OSA2LjI0Niw3MC41MDggQzYuMjQ2LDcwLjk5NCA2LjUxNyw3MS4zMiA2LjkyLDcxLjMyIEM3LjA3OCw3MS4zMiA3LjI0Myw3MS4yNzEgNy40MTEsNzEuMTc0IEM3Ljk1OSw3MC44NTcgOC4zODksNzAuMTE3IDguMzg5LDY5LjQ4NyBDOC4zODksNjkuMDAxIDguMTE3LDY4LjY3NCA3LjcxNCw2OC42NzQiIGlkPSJGaWxsLTE0IiBmaWxsPSIjODA5N0EyIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTYuOTIsNzAuOTQ3IEM2LjY0OSw3MC45NDcgNi42MjEsNzAuNjQgNi42MjEsNzAuNTA4IEM2LjYyMSw3MC4wMTcgNi45ODIsNjkuMzkyIDcuNDExLDY5LjE0NSBDNy41MjEsNjkuMDgyIDcuNjI1LDY5LjA0OSA3LjcxNCw2OS4wNDkgQzcuOTg2LDY5LjA0OSA4LjAxNSw2OS4zNTUgOC4wMTUsNjkuNDg3IEM4LjAxNSw2OS45NzggNy42NTIsNzAuNjAzIDcuMjI0LDcwLjg1MSBDNy4xMTUsNzAuOTE0IDcuMDEsNzAuOTQ3IDYuOTIsNzAuOTQ3IE03LjcxNCw2OC44NjEgQzcuNTk1LDY4Ljg2MSA3LjQ2LDY4LjkgNy4zMTcsNjguOTgyIEM2LjgyOSw2OS4yNjUgNi40MzMsNjkuOTQ4IDYuNDMzLDcwLjUwOCBDNi40MzMsNzAuOTA1IDYuNjMxLDcxLjEzMyA2LjkyLDcxLjEzMyBDNy4wMzksNzEuMTMzIDcuMTc0LDcxLjA5NCA3LjMxNyw3MS4wMTIgQzcuODA2LDcwLjczIDguMjAyLDcwLjA0NyA4LjIwMiw2OS40ODcgQzguMjAyLDY5LjA5IDguMDAzLDY4Ljg2MSA3LjcxNCw2OC44NjEiIGlkPSJGaWxsLTE1IiBmaWxsPSIjODA5N0EyIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTcuNDQ0LDg1LjM1IEM3LjcwOCw4NS4xOTggNy45MjEsODUuMzE5IDcuOTIxLDg1LjYyMiBDNy45MjEsODUuOTI1IDcuNzA4LDg2LjI5MiA3LjQ0NCw4Ni40NDQgQzcuMTgxLDg2LjU5NyA2Ljk2Nyw4Ni40NzUgNi45NjcsODYuMTczIEM2Ljk2Nyw4NS44NzEgNy4xODEsODUuNTAyIDcuNDQ0LDg1LjM1IiBpZD0iRmlsbC0xNiIgZmlsbD0iI0ZGRkZGRiI+PC9wYXRoPgogICAgICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik03LjIzLDg2LjUxIEM3LjA3NCw4Ni41MSA2Ljk2Nyw4Ni4zODcgNi45NjcsODYuMTczIEM2Ljk2Nyw4NS44NzEgNy4xODEsODUuNTAyIDcuNDQ0LDg1LjM1IEM3LjUyMSw4NS4zMDUgNy41OTQsODUuMjg0IDcuNjU4LDg1LjI4NCBDNy44MTQsODUuMjg0IDcuOTIxLDg1LjQwOCA3LjkyMSw4NS42MjIgQzcuOTIxLDg1LjkyNSA3LjcwOCw4Ni4yOTIgNy40NDQsODYuNDQ0IEM3LjM2Nyw4Ni40ODkgNy4yOTQsODYuNTEgNy4yMyw4Ni41MSBNNy42NTgsODUuMDk4IEM3LjU1OCw4NS4wOTggNy40NTUsODUuMTI3IDcuMzUxLDg1LjE4OCBDNy4wMzEsODUuMzczIDYuNzgxLDg1LjgwNiA2Ljc4MSw4Ni4xNzMgQzYuNzgxLDg2LjQ4MiA2Ljk2Niw4Ni42OTcgNy4yMyw4Ni42OTcgQzcuMzMsODYuNjk3IDcuNDMzLDg2LjY2NiA3LjUzOCw4Ni42MDcgQzcuODU4LDg2LjQyMiA4LjEwOCw4NS45ODkgOC4xMDgsODUuNjIyIEM4LjEwOCw4NS4zMTMgNy45MjMsODUuMDk4IDcuNjU4LDg1LjA5OCIgaWQ9IkZpbGwtMTciIGZpbGw9IiM4MDk3QTIiPjwvcGF0aD4KICAgICAgICAgICAgICAgICAgICA8cGF0aCBkPSJNNy4yMyw4Ni4zMjIgTDcuMTU0LDg2LjE3MyBDNy4xNTQsODUuOTM4IDcuMzMzLDg1LjYyOSA3LjUzOCw4NS41MTIgTDcuNjU4LDg1LjQ3MSBMNy43MzQsODUuNjIyIEM3LjczNCw4NS44NTYgNy41NTUsODYuMTY0IDcuMzUxLDg2LjI4MiBMNy4yMyw4Ni4zMjIgTTcuNjU4LDg1LjI4NCBDNy41OTQsODUuMjg0IDcuNTIxLDg1LjMwNSA3LjQ0NCw4NS4zNSBDNy4xODEsODUuNTAyIDYuOTY3LDg1Ljg3MSA2Ljk2Nyw4Ni4xNzMgQzYuOTY3LDg2LjM4NyA3LjA3NCw4Ni41MSA3LjIzLDg2LjUxIEM3LjI5NCw4Ni41MSA3LjM2Nyw4Ni40ODkgNy40NDQsODYuNDQ0IEM3LjcwOCw4Ni4yOTIgNy45MjEsODUuOTI1IDcuOTIxLDg1LjYyMiBDNy45MjEsODUuNDA4IDcuODE0LDg1LjI4NCA3LjY1OCw4NS4yODQiIGlkPSJGaWxsLTE4IiBmaWxsPSIjODA5N0EyIj48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTc3LjI3OCw3Ljc2OSBMNzcuMjc4LDUxLjQzNiBMMTAuMjA4LDkwLjE2IEwxMC4yMDgsNDYuNDkzIEw3Ny4yNzgsNy43NjkiIGlkPSJGaWxsLTE5IiBmaWxsPSIjNDU1QTY0Ij48L3BhdGg+CiAgICAgICAgICAgICAgICAgICAgPHBhdGggZD0iTTEwLjA4Myw5MC4zNzUgTDEwLjA4Myw0Ni40MjEgTDEwLjE0Niw0Ni4zODUgTDc3LjQwMyw3LjU1NCBMNzcuNDAzLDUxLjUwOCBMNzcuMzQxLDUxLjU0NCBMMTAuMDgzLDkwLjM3NSBMMTAuMDgzLDkwLjM3NSBaIE0xMC4zMzMsNDYuNTY0IEwxMC4zMzMsODkuOTQ0IEw3Ny4xNTQsNTEuMzY1IEw3Ny4xNTQsNy45ODUgTDEwLjMzMyw0Ni41NjQgTDEwLjMzMyw0Ni41NjQgWiIgaWQ9IkZpbGwtMjAiIGZpbGw9IiM2MDdEOEIiPjwvcGF0aD4KICAgICAgICAgICAgICAgIDwvZz4KICAgICAgICAgICAgICAgIDxwYXRoIGQ9Ik0xMjUuNzM3LDg4LjY0NyBMMTE4LjA5OCw5MS45ODEgTDExOC4wOTgsODQgTDEwNi42MzksODguNzEzIEwxMDYuNjM5LDk2Ljk4MiBMOTksMTAwLjMxNSBMMTEyLjM2OSwxMDMuOTYxIEwxMjUuNzM3LDg4LjY0NyIgaWQ9IkltcG9ydGVkLUxheWVycy1Db3B5LTIiIGZpbGw9IiM0NTVBNjQiIHNrZXRjaDp0eXBlPSJNU1NoYXBlR3JvdXAiPjwvcGF0aD4KICAgICAgICAgICAgPC9nPgogICAgICAgIDwvZz4KICAgIDwvZz4KPC9zdmc+"};var ce="CardboardV1",ue="WEBVR_CARDBOARD_VIEWER";function he(e){try{this.selectedKey=localStorage.getItem(ue)}catch(e){console.error("Failed to load viewer profile: %s",e)}this.selectedKey||(this.selectedKey=e||ce),this.dialog=this.createDialog_(W.Viewers),this.root=null,this.onChangeCallbacks_=[]}he.prototype.show=function(e){this.root=e,e.appendChild(this.dialog),this.dialog.querySelector("#"+this.selectedKey).checked=!0,this.dialog.style.display="block"},he.prototype.hide=function(){this.root&&this.root.contains(this.dialog)&&this.root.removeChild(this.dialog),this.dialog.style.display="none"},he.prototype.getCurrentViewer=function(){return W.Viewers[this.selectedKey]},he.prototype.getSelectedKey_=function(){var e=this.dialog.querySelector("input[name=field]:checked");return e?e.id:null},he.prototype.onChange=function(e){this.onChangeCallbacks_.push(e)},he.prototype.fireOnChange_=function(e){for(var t=0;t.5&&(this.noSleepVideo.currentTime=Math.random())}.bind(this)))}return n(e,[{key:"enable",value:function(){a?(this.disable(),this.noSleepTimer=window.setInterval(function(){window.location.href="/",window.setTimeout(window.stop,0)},15e3)):this.noSleepVideo.play()}},{key:"disable",value:function(){a?this.noSleepTimer&&(window.clearInterval(this.noSleepTimer),this.noSleepTimer=null):this.noSleepVideo.pause()}}]),e}();e.exports=o},function(e,t,i){e.exports="data:video/mp4;base64,AAAAIGZ0eXBtcDQyAAACAGlzb21pc28yYXZjMW1wNDEAAAAIZnJlZQAACKBtZGF0AAAC8wYF///v3EXpvebZSLeWLNgg2SPu73gyNjQgLSBjb3JlIDE0MiByMjQ3OSBkZDc5YTYxIC0gSC4yNjQvTVBFRy00IEFWQyBjb2RlYyAtIENvcHlsZWZ0IDIwMDMtMjAxNCAtIGh0dHA6Ly93d3cudmlkZW9sYW4ub3JnL3gyNjQuaHRtbCAtIG9wdGlvbnM6IGNhYmFjPTEgcmVmPTEgZGVibG9jaz0xOjA6MCBhbmFseXNlPTB4MToweDExMSBtZT1oZXggc3VibWU9MiBwc3k9MSBwc3lfcmQ9MS4wMDowLjAwIG1peGVkX3JlZj0wIG1lX3JhbmdlPTE2IGNocm9tYV9tZT0xIHRyZWxsaXM9MCA4eDhkY3Q9MCBjcW09MCBkZWFkem9uZT0yMSwxMSBmYXN0X3Bza2lwPTEgY2hyb21hX3FwX29mZnNldD0wIHRocmVhZHM9NiBsb29rYWhlYWRfdGhyZWFkcz0xIHNsaWNlZF90aHJlYWRzPTAgbnI9MCBkZWNpbWF0ZT0xIGludGVybGFjZWQ9MCBibHVyYXlfY29tcGF0PTAgY29uc3RyYWluZWRfaW50cmE9MCBiZnJhbWVzPTMgYl9weXJhbWlkPTIgYl9hZGFwdD0xIGJfYmlhcz0wIGRpcmVjdD0xIHdlaWdodGI9MSBvcGVuX2dvcD0wIHdlaWdodHA9MSBrZXlpbnQ9MzAwIGtleWludF9taW49MzAgc2NlbmVjdXQ9NDAgaW50cmFfcmVmcmVzaD0wIHJjX2xvb2thaGVhZD0xMCByYz1jcmYgbWJ0cmVlPTEgY3JmPTIwLjAgcWNvbXA9MC42MCBxcG1pbj0wIHFwbWF4PTY5IHFwc3RlcD00IHZidl9tYXhyYXRlPTIwMDAwIHZidl9idWZzaXplPTI1MDAwIGNyZl9tYXg9MC4wIG5hbF9ocmQ9bm9uZSBmaWxsZXI9MCBpcF9yYXRpbz0xLjQwIGFxPTE6MS4wMACAAAAAOWWIhAA3//p+C7v8tDDSTjf97w55i3SbRPO4ZY+hkjD5hbkAkL3zpJ6h/LR1CAABzgB1kqqzUorlhQAAAAxBmiQYhn/+qZYADLgAAAAJQZ5CQhX/AAj5IQADQGgcIQADQGgcAAAACQGeYUQn/wALKCEAA0BoHAAAAAkBnmNEJ/8ACykhAANAaBwhAANAaBwAAAANQZpoNExDP/6plgAMuSEAA0BoHAAAAAtBnoZFESwr/wAI+SEAA0BoHCEAA0BoHAAAAAkBnqVEJ/8ACykhAANAaBwAAAAJAZ6nRCf/AAsoIQADQGgcIQADQGgcAAAADUGarDRMQz/+qZYADLghAANAaBwAAAALQZ7KRRUsK/8ACPkhAANAaBwAAAAJAZ7pRCf/AAsoIQADQGgcIQADQGgcAAAACQGe60Qn/wALKCEAA0BoHAAAAA1BmvA0TEM//qmWAAy5IQADQGgcIQADQGgcAAAAC0GfDkUVLCv/AAj5IQADQGgcAAAACQGfLUQn/wALKSEAA0BoHCEAA0BoHAAAAAkBny9EJ/8ACyghAANAaBwAAAANQZs0NExDP/6plgAMuCEAA0BoHAAAAAtBn1JFFSwr/wAI+SEAA0BoHCEAA0BoHAAAAAkBn3FEJ/8ACyghAANAaBwAAAAJAZ9zRCf/AAsoIQADQGgcIQADQGgcAAAADUGbeDRMQz/+qZYADLkhAANAaBwAAAALQZ+WRRUsK/8ACPghAANAaBwhAANAaBwAAAAJAZ+1RCf/AAspIQADQGgcAAAACQGft0Qn/wALKSEAA0BoHCEAA0BoHAAAAA1Bm7w0TEM//qmWAAy4IQADQGgcAAAAC0Gf2kUVLCv/AAj5IQADQGgcAAAACQGf+UQn/wALKCEAA0BoHCEAA0BoHAAAAAkBn/tEJ/8ACykhAANAaBwAAAANQZvgNExDP/6plgAMuSEAA0BoHCEAA0BoHAAAAAtBnh5FFSwr/wAI+CEAA0BoHAAAAAkBnj1EJ/8ACyghAANAaBwhAANAaBwAAAAJAZ4/RCf/AAspIQADQGgcAAAADUGaJDRMQz/+qZYADLghAANAaBwAAAALQZ5CRRUsK/8ACPkhAANAaBwhAANAaBwAAAAJAZ5hRCf/AAsoIQADQGgcAAAACQGeY0Qn/wALKSEAA0BoHCEAA0BoHAAAAA1Bmmg0TEM//qmWAAy5IQADQGgcAAAAC0GehkUVLCv/AAj5IQADQGgcIQADQGgcAAAACQGepUQn/wALKSEAA0BoHAAAAAkBnqdEJ/8ACyghAANAaBwAAAANQZqsNExDP/6plgAMuCEAA0BoHCEAA0BoHAAAAAtBnspFFSwr/wAI+SEAA0BoHAAAAAkBnulEJ/8ACyghAANAaBwhAANAaBwAAAAJAZ7rRCf/AAsoIQADQGgcAAAADUGa8DRMQz/+qZYADLkhAANAaBwhAANAaBwAAAALQZ8ORRUsK/8ACPkhAANAaBwAAAAJAZ8tRCf/AAspIQADQGgcIQADQGgcAAAACQGfL0Qn/wALKCEAA0BoHAAAAA1BmzQ0TEM//qmWAAy4IQADQGgcAAAAC0GfUkUVLCv/AAj5IQADQGgcIQADQGgcAAAACQGfcUQn/wALKCEAA0BoHAAAAAkBn3NEJ/8ACyghAANAaBwhAANAaBwAAAANQZt4NExC//6plgAMuSEAA0BoHAAAAAtBn5ZFFSwr/wAI+CEAA0BoHCEAA0BoHAAAAAkBn7VEJ/8ACykhAANAaBwAAAAJAZ+3RCf/AAspIQADQGgcAAAADUGbuzRMQn/+nhAAYsAhAANAaBwhAANAaBwAAAAJQZ/aQhP/AAspIQADQGgcAAAACQGf+UQn/wALKCEAA0BoHCEAA0BoHCEAA0BoHCEAA0BoHCEAA0BoHCEAA0BoHAAACiFtb292AAAAbG12aGQAAAAA1YCCX9WAgl8AAAPoAAAH/AABAAABAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAGGlvZHMAAAAAEICAgAcAT////v7/AAAF+XRyYWsAAABcdGtoZAAAAAPVgIJf1YCCXwAAAAEAAAAAAAAH0AAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAEAAAAAAygAAAMoAAAAAACRlZHRzAAAAHGVsc3QAAAAAAAAAAQAAB9AAABdwAAEAAAAABXFtZGlhAAAAIG1kaGQAAAAA1YCCX9WAgl8AAV+QAAK/IFXEAAAAAAAtaGRscgAAAAAAAAAAdmlkZQAAAAAAAAAAAAAAAFZpZGVvSGFuZGxlcgAAAAUcbWluZgAAABR2bWhkAAAAAQAAAAAAAAAAAAAAJGRpbmYAAAAcZHJlZgAAAAAAAAABAAAADHVybCAAAAABAAAE3HN0YmwAAACYc3RzZAAAAAAAAAABAAAAiGF2YzEAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAygDKAEgAAABIAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAY//8AAAAyYXZjQwFNQCj/4QAbZ01AKOyho3ySTUBAQFAAAAMAEAAr8gDxgxlgAQAEaO+G8gAAABhzdHRzAAAAAAAAAAEAAAA8AAALuAAAABRzdHNzAAAAAAAAAAEAAAABAAAB8GN0dHMAAAAAAAAAPAAAAAEAABdwAAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAADqYAAAAAQAAF3AAAAABAAAAAAAAAAEAAAu4AAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAADqYAAAAAQAAF3AAAAABAAAAAAAAAAEAAAu4AAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAADqYAAAAAQAAF3AAAAABAAAAAAAAAAEAAAu4AAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAADqYAAAAAQAAF3AAAAABAAAAAAAAAAEAAAu4AAAAAQAAOpgAAAABAAAXcAAAAAEAAAAAAAAAAQAAC7gAAAABAAA6mAAAAAEAABdwAAAAAQAAAAAAAAABAAALuAAAAAEAAC7gAAAAAQAAF3AAAAABAAAAAAAAABxzdHNjAAAAAAAAAAEAAAABAAAAAQAAAAEAAAEEc3RzegAAAAAAAAAAAAAAPAAAAzQAAAAQAAAADQAAAA0AAAANAAAAEQAAAA8AAAANAAAADQAAABEAAAAPAAAADQAAAA0AAAARAAAADwAAAA0AAAANAAAAEQAAAA8AAAANAAAADQAAABEAAAAPAAAADQAAAA0AAAARAAAADwAAAA0AAAANAAAAEQAAAA8AAAANAAAADQAAABEAAAAPAAAADQAAAA0AAAARAAAADwAAAA0AAAANAAAAEQAAAA8AAAANAAAADQAAABEAAAAPAAAADQAAAA0AAAARAAAADwAAAA0AAAANAAAAEQAAAA8AAAANAAAADQAAABEAAAANAAAADQAAAQBzdGNvAAAAAAAAADwAAAAwAAADZAAAA3QAAAONAAADoAAAA7kAAAPQAAAD6wAAA/4AAAQXAAAELgAABEMAAARcAAAEbwAABIwAAAShAAAEugAABM0AAATkAAAE/wAABRIAAAUrAAAFQgAABV0AAAVwAAAFiQAABaAAAAW1AAAFzgAABeEAAAX+AAAGEwAABiwAAAY/AAAGVgAABnEAAAaEAAAGnQAABrQAAAbPAAAG4gAABvUAAAcSAAAHJwAAB0AAAAdTAAAHcAAAB4UAAAeeAAAHsQAAB8gAAAfjAAAH9gAACA8AAAgmAAAIQQAACFQAAAhnAAAIhAAACJcAAAMsdHJhawAAAFx0a2hkAAAAA9WAgl/VgIJfAAAAAgAAAAAAAAf8AAAAAAAAAAAAAAABAQAAAAABAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAACsm1kaWEAAAAgbWRoZAAAAADVgIJf1YCCXwAArEQAAWAAVcQAAAAAACdoZGxyAAAAAAAAAABzb3VuAAAAAAAAAAAAAAAAU3RlcmVvAAAAAmNtaW5mAAAAEHNtaGQAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAidzdGJsAAAAZ3N0c2QAAAAAAAAAAQAAAFdtcDRhAAAAAAAAAAEAAAAAAAAAAAACABAAAAAArEQAAAAAADNlc2RzAAAAAAOAgIAiAAIABICAgBRAFQAAAAADDUAAAAAABYCAgAISEAaAgIABAgAAABhzdHRzAAAAAAAAAAEAAABYAAAEAAAAABxzdHNjAAAAAAAAAAEAAAABAAAAAQAAAAEAAAAUc3RzegAAAAAAAAAGAAAAWAAAAXBzdGNvAAAAAAAAAFgAAAOBAAADhwAAA5oAAAOtAAADswAAA8oAAAPfAAAD5QAAA/gAAAQLAAAEEQAABCgAAAQ9AAAEUAAABFYAAARpAAAEgAAABIYAAASbAAAErgAABLQAAATHAAAE3gAABPMAAAT5AAAFDAAABR8AAAUlAAAFPAAABVEAAAVXAAAFagAABX0AAAWDAAAFmgAABa8AAAXCAAAFyAAABdsAAAXyAAAF+AAABg0AAAYgAAAGJgAABjkAAAZQAAAGZQAABmsAAAZ+AAAGkQAABpcAAAauAAAGwwAABskAAAbcAAAG7wAABwYAAAcMAAAHIQAABzQAAAc6AAAHTQAAB2QAAAdqAAAHfwAAB5IAAAeYAAAHqwAAB8IAAAfXAAAH3QAAB/AAAAgDAAAICQAACCAAAAg1AAAIOwAACE4AAAhhAAAIeAAACH4AAAiRAAAIpAAACKoAAAiwAAAItgAACLwAAAjCAAAAFnVkdGEAAAAObmFtZVN0ZXJlbwAAAHB1ZHRhAAAAaG1ldGEAAAAAAAAAIWhkbHIAAAAAAAAAAG1kaXJhcHBsAAAAAAAAAAAAAAAAO2lsc3QAAAAzqXRvbwAAACtkYXRhAAAAAQAAAABIYW5kQnJha2UgMC4xMC4yIDIwMTUwNjExMDA="}])},e.exports=i()}))&&le.__esModule?le.default:le,pe=1e3,fe=[0,0,.5,1],ge=[.5,0,.5,1],me=window.requestAnimationFrame,Me=window.cancelAnimationFrame;function ye(e){Object.defineProperties(this,{hasPosition:{writable:!1,enumerable:!0,value:e.hasPosition},hasExternalDisplay:{writable:!1,enumerable:!0,value:e.hasExternalDisplay},canPresent:{writable:!1,enumerable:!0,value:e.canPresent},maxLayers:{writable:!1,enumerable:!0,value:e.maxLayers},hasOrientation:{enumerable:!0,get:function(){return D("VRDisplayCapabilities.prototype.hasOrientation","VRDisplay.prototype.getFrameData"),e.hasOrientation}}})}function ve(e){var t=!("wakelock"in(e=e||{}))||e.wakelock;this.isPolyfilled=!0,this.displayId=pe++,this.displayName="",this.depthNear=.01,this.depthFar=1e4,this.isPresenting=!1,Object.defineProperty(this,"isConnected",{get:function(){return D("VRDisplay.prototype.isConnected","VRDisplayCapabilities.prototype.hasExternalDisplay"),!1}}),this.capabilities=new ye({hasPosition:!1,hasOrientation:!1,hasExternalDisplay:!1,canPresent:!1,maxLayers:1}),this.stageParameters=null,this.waitingForPresent_=!1,this.layer_=null,this.originalParent_=null,this.fullscreenElement_=null,this.fullscreenWrapper_=null,this.fullscreenElementCachedStyle_=null,this.fullscreenEventTarget_=null,this.fullscreenChangeHandler_=null,this.fullscreenErrorHandler_=null,t&&A()&&(this.wakelock_=new de)}ve.prototype.getFrameData=function(e){return E(e,this._getPose(),this)},ve.prototype.getPose=function(){return D("VRDisplay.prototype.getPose","VRDisplay.prototype.getFrameData"),this._getPose()},ve.prototype.resetPose=function(){return D("VRDisplay.prototype.resetPose"),this._resetPose()},ve.prototype.getImmediatePose=function(){return D("VRDisplay.prototype.getImmediatePose","VRDisplay.prototype.getFrameData"),this._getPose()},ve.prototype.requestAnimationFrame=function(e){return me(e)},ve.prototype.cancelAnimationFrame=function(e){return Me(e)},ve.prototype.wrapForFullscreen=function(e){if(c())return e;if(!this.fullscreenWrapper_){this.fullscreenWrapper_=document.createElement("div");var t=["height: "+Math.min(screen.height,screen.width)+"px !important","top: 0 !important","left: 0 !important","right: 0 !important","border: 0","margin: 0","padding: 0","z-index: 999999 !important","position: fixed"];this.fullscreenWrapper_.setAttribute("style",t.join("; ")+";"),this.fullscreenWrapper_.classList.add("webvr-polyfill-fullscreen-wrapper")}if(this.fullscreenElement_==e)return this.fullscreenWrapper_;if(this.fullscreenElement_&&(this.originalParent_?this.originalParent_.appendChild(this.fullscreenElement_):this.fullscreenElement_.parentElement.removeChild(this.fullscreenElement_)),this.fullscreenElement_=e,this.originalParent_=e.parentElement,this.originalParent_||document.body.appendChild(e),!this.fullscreenWrapper_.parentElement){var i=this.fullscreenElement_.parentElement;i.insertBefore(this.fullscreenWrapper_,this.fullscreenElement_),i.removeChild(this.fullscreenElement_)}this.fullscreenWrapper_.insertBefore(this.fullscreenElement_,this.fullscreenWrapper_.firstChild),this.fullscreenElementCachedStyle_=this.fullscreenElement_.getAttribute("style");var n=this;return function(){if(n.fullscreenElement_){var e=["position: absolute","top: 0","left: 0","width: "+Math.max(screen.width,screen.height)+"px","height: "+Math.min(screen.height,screen.width)+"px","border: 0","margin: 0","padding: 0"];n.fullscreenElement_.setAttribute("style",e.join("; ")+";")}}(),this.fullscreenWrapper_},ve.prototype.removeFullscreenWrapper=function(){if(this.fullscreenElement_){var e=this.fullscreenElement_;this.fullscreenElementCachedStyle_?e.setAttribute("style",this.fullscreenElementCachedStyle_):e.removeAttribute("style"),this.fullscreenElement_=null,this.fullscreenElementCachedStyle_=null;var t=this.fullscreenWrapper_.parentElement;return this.fullscreenWrapper_.removeChild(e),this.originalParent_===t?t.insertBefore(e,this.fullscreenWrapper_):this.originalParent_&&this.originalParent_.appendChild(e),t.removeChild(this.fullscreenWrapper_),e}},ve.prototype.requestPresent=function(e){var t=this.isPresenting,i=this;return e instanceof Array||(D("VRDisplay.prototype.requestPresent with non-array argument","an array of VRLayers as the first argument"),e=[e]),new Promise(function(n,r){if(i.capabilities.canPresent)if(0==e.length||e.length>i.capabilities.maxLayers)r(new Error("Invalid number of layers."));else{var a=e[0];if(a.source){var o=a.leftBounds||fe,s=a.rightBounds||ge;if(t){var h=i.layer_;h.source!==a.source&&(h.source=a.source);for(var l=0;l<4;l++)h.leftBounds[l]=o[l],h.rightBounds[l]=s[l];return i.wrapForFullscreen(i.layer_.source),i.updatePresent_(),void n()}if(i.layer_={predistorted:a.predistorted,source:a.source,leftBounds:o.slice(0),rightBounds:s.slice(0)},i.waitingForPresent_=!1,i.layer_&&i.layer_.source){var d=i.wrapForFullscreen(i.layer_.source);i.addFullscreenListeners_(d,function(){var e=document.fullscreenElement||document.webkitFullscreenElement||document.mozFullScreenElement||document.msFullscreenElement;i.isPresenting=d===e,i.isPresenting?(screen.orientation&&screen.orientation.lock&&screen.orientation.lock("landscape-primary").catch(function(e){console.error("screen.orientation.lock() failed due to",e.message)}),i.waitingForPresent_=!1,i.beginPresent_(),n()):(screen.orientation&&screen.orientation.unlock&&screen.orientation.unlock(),i.removeFullscreenWrapper(),i.disableWakeLock(),i.endPresent_(),i.removeFullscreenListeners_()),i.fireVRDisplayPresentChange_()},function(){i.waitingForPresent_&&(i.removeFullscreenWrapper(),i.removeFullscreenListeners_(),i.disableWakeLock(),i.waitingForPresent_=!1,i.isPresenting=!1,r(new Error("Unable to present.")))}),function(e){if(u())return!1;if(e.requestFullscreen)e.requestFullscreen();else if(e.webkitRequestFullscreen)e.webkitRequestFullscreen();else if(e.mozRequestFullScreen)e.mozRequestFullScreen();else{if(!e.msRequestFullscreen)return!1;e.msRequestFullscreen()}return!0}(d)?(i.enableWakeLock(),i.waitingForPresent_=!0):(c()||u())&&(i.enableWakeLock(),i.isPresenting=!0,i.beginPresent_(),i.fireVRDisplayPresentChange_(),n())}i.waitingForPresent_||c()||(M(),r(new Error("Unable to present.")))}else n()}else r(new Error("VRDisplay is not capable of presenting."))})},ve.prototype.exitPresent=function(){var e=this.isPresenting,t=this;return this.isPresenting=!1,this.layer_=null,this.disableWakeLock(),new Promise(function(i,n){e?(!M()&&c()&&(t.endPresent_(),t.fireVRDisplayPresentChange_()),u()&&(t.removeFullscreenWrapper(),t.removeFullscreenListeners_(),t.endPresent_(),t.fireVRDisplayPresentChange_()),i()):n(new Error("Was not presenting to VRDisplay."))})},ve.prototype.getLayers=function(){return this.layer_?[this.layer_]:[]},ve.prototype.fireVRDisplayPresentChange_=function(){var e=new CustomEvent("vrdisplaypresentchange",{detail:{display:this}});window.dispatchEvent(e)},ve.prototype.fireVRDisplayConnect_=function(){var e=new CustomEvent("vrdisplayconnect",{detail:{display:this}});window.dispatchEvent(e)},ve.prototype.addFullscreenListeners_=function(e,t,i){this.removeFullscreenListeners_(),this.fullscreenEventTarget_=e,this.fullscreenChangeHandler_=t,this.fullscreenErrorHandler_=i,t&&(document.fullscreenEnabled?e.addEventListener("fullscreenchange",t,!1):document.webkitFullscreenEnabled?e.addEventListener("webkitfullscreenchange",t,!1):document.mozFullScreenEnabled?document.addEventListener("mozfullscreenchange",t,!1):document.msFullscreenEnabled&&e.addEventListener("msfullscreenchange",t,!1)),i&&(document.fullscreenEnabled?e.addEventListener("fullscreenerror",i,!1):document.webkitFullscreenEnabled?e.addEventListener("webkitfullscreenerror",i,!1):document.mozFullScreenEnabled?document.addEventListener("mozfullscreenerror",i,!1):document.msFullscreenEnabled&&e.addEventListener("msfullscreenerror",i,!1))},ve.prototype.removeFullscreenListeners_=function(){if(this.fullscreenEventTarget_){var e=this.fullscreenEventTarget_;if(this.fullscreenChangeHandler_){var t=this.fullscreenChangeHandler_;e.removeEventListener("fullscreenchange",t,!1),e.removeEventListener("webkitfullscreenchange",t,!1),document.removeEventListener("mozfullscreenchange",t,!1),e.removeEventListener("msfullscreenchange",t,!1)}if(this.fullscreenErrorHandler_){var i=this.fullscreenErrorHandler_;e.removeEventListener("fullscreenerror",i,!1),e.removeEventListener("webkitfullscreenerror",i,!1),document.removeEventListener("mozfullscreenerror",i,!1),e.removeEventListener("msfullscreenerror",i,!1)}this.fullscreenEventTarget_=null,this.fullscreenChangeHandler_=null,this.fullscreenErrorHandler_=null}},ve.prototype.enableWakeLock=function(){this.wakelock_&&this.wakelock_.enable()},ve.prototype.disableWakeLock=function(){this.wakelock_&&this.wakelock_.disable()},ve.prototype.beginPresent_=function(){},ve.prototype.endPresent_=function(){},ve.prototype.submitFrame=function(e){},ve.prototype.getEyeParameters=function(e){return null};var Ae={ADDITIONAL_VIEWERS:[],DEFAULT_VIEWER:"",MOBILE_WAKE_LOCK:!0,DEBUG:!1,DPDB_URL:"https://dpdb.webvr.rocks/dpdb.json",K_FILTER:.98,PREDICTION_TIME_S:.04,CARDBOARD_UI_DISABLED:!1,ROTATE_INSTRUCTIONS_DISABLED:!1,YAW_ONLY:!1,BUFFER_SCALE:.5,DIRTY_SUBMIT_FRAME_BINDINGS:!1},we="left",xe="right";function Ee(e){var t=w({},Ae);e=w(t,e||{}),ve.call(this,{wakelock:e.MOBILE_WAKE_LOCK}),this.config=e,this.displayName="Cardboard VRDisplay",this.capabilities=new ye({hasPosition:!1,hasOrientation:!0,hasExternalDisplay:!1,canPresent:!0,maxLayers:1}),this.stageParameters=null,this.bufferScale_=this.config.BUFFER_SCALE,this.poseSensor_=new oe(this.config),this.distorter_=null,this.cardboardUI_=null,this.dpdb_=new q(this.config.DPDB_URL,this.onDeviceParamsUpdated_.bind(this)),this.deviceInfo_=new W(this.dpdb_.getDeviceParams(),e.ADDITIONAL_VIEWERS),this.viewerSelector_=new he(e.DEFAULT_VIEWER),this.viewerSelector_.onChange(this.onViewerChanged_.bind(this)),this.deviceInfo_.setViewer(this.viewerSelector_.getCurrentViewer()),this.config.ROTATE_INSTRUCTIONS_DISABLED||(this.rotateInstructions_=new se),c()&&window.addEventListener("resize",this.onResize_.bind(this))}return Ee.prototype=Object.create(ve.prototype),Ee.prototype._getPose=function(){return{position:null,orientation:this.poseSensor_.getOrientation(),linearVelocity:null,linearAcceleration:null,angularVelocity:null,angularAcceleration:null}},Ee.prototype._resetPose=function(){this.poseSensor_.resetPose&&this.poseSensor_.resetPose()},Ee.prototype._getFieldOfView=function(e){var t;if(e==we)t=this.deviceInfo_.getFieldOfViewLeftEye();else{if(e!=xe)return console.error("Invalid eye provided: %s",e),null;t=this.deviceInfo_.getFieldOfViewRightEye()}return t},Ee.prototype._getEyeOffset=function(e){var t;if(e==we)t=[.5*-this.deviceInfo_.viewer.interLensDistance,0,0];else{if(e!=xe)return console.error("Invalid eye provided: %s",e),null;t=[.5*this.deviceInfo_.viewer.interLensDistance,0,0]}return t},Ee.prototype.getEyeParameters=function(e){var t=this._getEyeOffset(e),i=this._getFieldOfView(e),n={offset:t,renderWidth:.5*this.deviceInfo_.device.width*this.bufferScale_,renderHeight:this.deviceInfo_.device.height*this.bufferScale_};return Object.defineProperty(n,"fieldOfView",{enumerable:!0,get:function(){return D("VRFieldOfView","VRFrameData's projection matrices"),i}}),n},Ee.prototype.onDeviceParamsUpdated_=function(e){this.config.DEBUG&&console.log("DPDB reported that device params were updated."),this.deviceInfo_.updateDeviceParams(e),this.distorter_&&this.distorter_.updateDeviceInfo(this.deviceInfo_)},Ee.prototype.updateBounds_=function(){this.layer_&&this.distorter_&&(this.layer_.leftBounds||this.layer_.rightBounds)&&this.distorter_.setTextureBounds(this.layer_.leftBounds,this.layer_.rightBounds)},Ee.prototype.beginPresent_=function(){var e=this.layer_.source.getContext("webgl");e||(e=this.layer_.source.getContext("experimental-webgl")),e||(e=this.layer_.source.getContext("webgl2")),e&&(this.layer_.predistorted?this.config.CARDBOARD_UI_DISABLED||(e.canvas.width=g()*this.bufferScale_,e.canvas.height=m()*this.bufferScale_,this.cardboardUI_=new z(e)):(this.config.CARDBOARD_UI_DISABLED||(this.cardboardUI_=new z(e)),this.distorter_=new _(e,this.cardboardUI_,this.config.BUFFER_SCALE,this.config.DIRTY_SUBMIT_FRAME_BINDINGS),this.distorter_.updateDeviceInfo(this.deviceInfo_)),this.cardboardUI_&&this.cardboardUI_.listen(function(e){this.viewerSelector_.show(this.layer_.source.parentElement),e.stopPropagation(),e.preventDefault()}.bind(this),function(e){this.exitPresent(),e.stopPropagation(),e.preventDefault()}.bind(this)),this.rotateInstructions_&&(f()&&A()?this.rotateInstructions_.showTemporarily(3e3,this.layer_.source.parentElement):this.rotateInstructions_.update()),this.orientationHandler=this.onOrientationChange_.bind(this),window.addEventListener("orientationchange",this.orientationHandler),this.vrdisplaypresentchangeHandler=this.updateBounds_.bind(this),window.addEventListener("vrdisplaypresentchange",this.vrdisplaypresentchangeHandler),this.fireVRDisplayDeviceParamsChange_())},Ee.prototype.endPresent_=function(){this.distorter_&&(this.distorter_.destroy(),this.distorter_=null),this.cardboardUI_&&(this.cardboardUI_.destroy(),this.cardboardUI_=null),this.rotateInstructions_&&this.rotateInstructions_.hide(),this.viewerSelector_.hide(),window.removeEventListener("orientationchange",this.orientationHandler),window.removeEventListener("vrdisplaypresentchange",this.vrdisplaypresentchangeHandler)},Ee.prototype.updatePresent_=function(){this.endPresent_(),this.beginPresent_()},Ee.prototype.submitFrame=function(e){if(this.distorter_)this.updateBounds_(),this.distorter_.submitFrame();else if(this.cardboardUI_&&this.layer_){var t=this.layer_.source.getContext("webgl").canvas;t.width==this.lastWidth&&t.height==this.lastHeight||this.cardboardUI_.onResize(),this.lastWidth=t.width,this.lastHeight=t.height,this.cardboardUI_.render()}},Ee.prototype.onOrientationChange_=function(e){this.viewerSelector_.hide(),this.rotateInstructions_&&this.rotateInstructions_.update(),this.onResize_()},Ee.prototype.onResize_=function(e){if(this.layer_){var t=this.layer_.source.getContext("webgl");t.canvas.setAttribute("style",["position: absolute","top: 0","left: 0","width: 100vw","height: 100vh","border: 0","margin: 0","padding: 0px","box-sizing: content-box"].join("; ")+";"),x(t.canvas)}},Ee.prototype.onViewerChanged_=function(e){this.deviceInfo_.setViewer(e),this.distorter_&&this.distorter_.updateDeviceInfo(this.deviceInfo_),this.fireVRDisplayDeviceParamsChange_()},Ee.prototype.fireVRDisplayDeviceParamsChange_=function(){var e=new CustomEvent("vrdisplaydeviceparamschange",{detail:{vrdisplay:this,deviceInfo:this.deviceInfo_}});window.dispatchEvent(e)},Ee.VRFrameData=function(){this.leftProjectionMatrix=new Float32Array(16),this.leftViewMatrix=new Float32Array(16),this.rightProjectionMatrix=new Float32Array(16),this.rightViewMatrix=new Float32Array(16),this.pose=null},Ee.VRDisplay=ve,Ee}()}))&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e,s={ADDITIONAL_VIEWERS:[],DEFAULT_VIEWER:"",PROVIDE_MOBILE_VRDISPLAY:!0,GET_VR_DISPLAYS_TIMEOUT:1e3,MOBILE_WAKE_LOCK:!0,DEBUG:!1,DPDB_URL:"https://dpdb.webvr.rocks/dpdb.json",K_FILTER:.98,PREDICTION_TIME_S:.04,TOUCH_PANNER_DISABLED:!0,CARDBOARD_UI_DISABLED:!1,ROTATE_INSTRUCTIONS_DISABLED:!1,YAW_ONLY:!1,BUFFER_SCALE:.5,DIRTY_SUBMIT_FRAME_BINDINGS:!1};function c(e){this.config=r(r({},s),e),this.polyfillDisplays=[],this.enabled=!1,this.hasNative="getVRDisplays"in navigator,this.native={},this.native.getVRDisplays=navigator.getVRDisplays,this.native.VRFrameData=window.VRFrameData,this.native.VRDisplay=window.VRDisplay,(!this.hasNative||this.config.PROVIDE_MOBILE_VRDISPLAY&&i())&&(this.enable(),this.getVRDisplays().then(function(e){e&&e[0]&&e[0].fireVRDisplayConnect_&&e[0].fireVRDisplayConnect_()}))}c.prototype.getPolyfillDisplays=function(){if(this._polyfillDisplaysPopulated)return this.polyfillDisplays;if(i()){var e=new o({ADDITIONAL_VIEWERS:this.config.ADDITIONAL_VIEWERS,DEFAULT_VIEWER:this.config.DEFAULT_VIEWER,MOBILE_WAKE_LOCK:this.config.MOBILE_WAKE_LOCK,DEBUG:this.config.DEBUG,DPDB_URL:this.config.DPDB_URL,CARDBOARD_UI_DISABLED:this.config.CARDBOARD_UI_DISABLED,K_FILTER:this.config.K_FILTER,PREDICTION_TIME_S:this.config.PREDICTION_TIME_S,TOUCH_PANNER_DISABLED:this.config.TOUCH_PANNER_DISABLED,ROTATE_INSTRUCTIONS_DISABLED:this.config.ROTATE_INSTRUCTIONS_DISABLED,YAW_ONLY:this.config.YAW_ONLY,BUFFER_SCALE:this.config.BUFFER_SCALE,DIRTY_SUBMIT_FRAME_BINDINGS:this.config.DIRTY_SUBMIT_FRAME_BINDINGS});this.polyfillDisplays.push(e)}return this._polyfillDisplaysPopulated=!0,this.polyfillDisplays},c.prototype.enable=function(){if(this.enabled=!0,this.hasNative&&this.native.VRFrameData){var e=this.native.VRFrameData,t=new this.native.VRFrameData,i=this.native.VRDisplay.prototype.getFrameData;window.VRDisplay.prototype.getFrameData=function(r){r instanceof e?i.call(this,r):(i.call(this,t),r.pose=t.pose,n(t.leftProjectionMatrix,r.leftProjectionMatrix),n(t.rightProjectionMatrix,r.rightProjectionMatrix),n(t.leftViewMatrix,r.leftViewMatrix),n(t.rightViewMatrix,r.rightViewMatrix))}}navigator.getVRDisplays=this.getVRDisplays.bind(this),window.VRDisplay=o.VRDisplay,window.VRFrameData=o.VRFrameData},c.prototype.getVRDisplays=function(){var e,t=this,i=this.config;if(!this.hasNative)return Promise.resolve(this.getPolyfillDisplays());var n,r=this.native.getVRDisplays.call(navigator),a=new Promise(function(t){e=setTimeout(function(){console.warn("Native WebVR implementation detected, but `getVRDisplays()` failed to resolve. Falling back to polyfill."),t([])},i.GET_VR_DISPLAYS_TIMEOUT)});return(n=[r,a],Promise.race?Promise.race(n):new Promise(function(e,t){for(var i=0;i0?i:t.getPolyfillDisplays()})},c.version="0.10.6",c.VRFrameData=o.VRFrameData,c.VRDisplay=o.VRDisplay;var u=Object.freeze({default:c}),h=u&&c||u;return void 0!==t&&t.window&&(t.document||(t.document=t.window.document),t.navigator||(t.navigator=t.window.navigator)),h}()}),c=(o=s)&&o.__esModule&&Object.prototype.hasOwnProperty.call(o,"default")?o.default:o;function u(){}void 0===Number.EPSILON&&(Number.EPSILON=Math.pow(2,-52)),void 0===Number.isInteger&&(Number.isInteger=function(e){return"number"==typeof e&&isFinite(e)&&Math.floor(e)===e}),void 0===Math.sign&&(Math.sign=function(e){return e<0?-1:e>0?1:+e}),"name"in Function.prototype==!1&&Object.defineProperty(Function.prototype,"name",{get:function(){return this.toString().match(/^\s*function\s*([^\(\s]*)/)[1]}}),void 0===Object.assign&&(Object.assign=function(e){if(null==e)throw new TypeError("Cannot convert undefined or null to object");for(var t=Object(e),i=1;i>8&255]+e[t>>16&255]+e[t>>24&255]+"-"+e[255&i]+e[i>>8&255]+"-"+e[i>>16&15|64]+e[i>>24&255]+"-"+e[63&n|128]+e[n>>8&255]+"-"+e[n>>16&255]+e[n>>24&255]+e[255&r]+e[r>>8&255]+e[r>>16&255]+e[r>>24&255]).toUpperCase()}}(),clamp:function(e,t,i){return Math.max(t,Math.min(i,e))},euclideanModulo:function(e,t){return(e%t+t)%t},mapLinear:function(e,t,i,n,r){return n+(e-t)*(r-n)/(i-t)},lerp:function(e,t,i){return(1-i)*e+i*t},smoothstep:function(e,t,i){return e<=t?0:e>=i?1:(e=(e-t)/(i-t))*e*(3-2*e)},smootherstep:function(e,t,i){return e<=t?0:e>=i?1:(e=(e-t)/(i-t))*e*e*(e*(6*e-15)+10)},randInt:function(e,t){return e+Math.floor(Math.random()*(t-e+1))},randFloat:function(e,t){return e+Math.random()*(t-e)},randFloatSpread:function(e){return e*(.5-Math.random())},degToRad:function(e){return e*zt.DEG2RAD},radToDeg:function(e){return e*zt.RAD2DEG},isPowerOfTwo:function(e){return 0==(e&e-1)&&0!==e},ceilPowerOfTwo:function(e){return Math.pow(2,Math.ceil(Math.log(e)/Math.LN2))},floorPowerOfTwo:function(e){return Math.pow(2,Math.floor(Math.log(e)/Math.LN2))}};function Rt(e,t){this.x=e||0,this.y=t||0}function Ut(){this.elements=[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],arguments.length>0&&console.error("THREE.Matrix4: the constructor no longer reads arguments. use .set() instead.")}function Pt(e,t,i,n){this._x=e||0,this._y=t||0,this._z=i||0,this._w=void 0!==n?n:1}function Bt(e,t,i){this.x=e||0,this.y=t||0,this.z=i||0}function kt(){this.elements=[1,0,0,0,1,0,0,0,1],arguments.length>0&&console.error("THREE.Matrix3: the constructor no longer reads arguments. use .set() instead.")}Object.defineProperties(Rt.prototype,{width:{get:function(){return this.x},set:function(e){this.x=e}},height:{get:function(){return this.y},set:function(e){this.y=e}}}),Object.assign(Rt.prototype,{isVector2:!0,set:function(e,t){return this.x=e,this.y=t,this},setScalar:function(e){return this.x=e,this.y=e,this},setX:function(e){return this.x=e,this},setY:function(e){return this.y=e,this},setComponent:function(e,t){switch(e){case 0:this.x=t;break;case 1:this.y=t;break;default:throw new Error("index is out of range: "+e)}return this},getComponent:function(e){switch(e){case 0:return this.x;case 1:return this.y;default:throw new Error("index is out of range: "+e)}},clone:function(){return new this.constructor(this.x,this.y)},copy:function(e){return this.x=e.x,this.y=e.y,this},add:function(e,t){return void 0!==t?(console.warn("THREE.Vector2: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(e,t)):(this.x+=e.x,this.y+=e.y,this)},addScalar:function(e){return this.x+=e,this.y+=e,this},addVectors:function(e,t){return this.x=e.x+t.x,this.y=e.y+t.y,this},addScaledVector:function(e,t){return this.x+=e.x*t,this.y+=e.y*t,this},sub:function(e,t){return void 0!==t?(console.warn("THREE.Vector2: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(e,t)):(this.x-=e.x,this.y-=e.y,this)},subScalar:function(e){return this.x-=e,this.y-=e,this},subVectors:function(e,t){return this.x=e.x-t.x,this.y=e.y-t.y,this},multiply:function(e){return this.x*=e.x,this.y*=e.y,this},multiplyScalar:function(e){return this.x*=e,this.y*=e,this},divide:function(e){return this.x/=e.x,this.y/=e.y,this},divideScalar:function(e){return this.multiplyScalar(1/e)},applyMatrix3:function(e){var t=this.x,i=this.y,n=e.elements;return this.x=n[0]*t+n[3]*i+n[6],this.y=n[1]*t+n[4]*i+n[7],this},min:function(e){return this.x=Math.min(this.x,e.x),this.y=Math.min(this.y,e.y),this},max:function(e){return this.x=Math.max(this.x,e.x),this.y=Math.max(this.y,e.y),this},clamp:function(e,t){return this.x=Math.max(e.x,Math.min(t.x,this.x)),this.y=Math.max(e.y,Math.min(t.y,this.y)),this},clampScalar:(h=new Rt,l=new Rt,function(e,t){return h.set(e,e),l.set(t,t),this.clamp(h,l)}),clampLength:function(e,t){var i=this.length();return this.divideScalar(i||1).multiplyScalar(Math.max(e,Math.min(t,i)))},floor:function(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this},ceil:function(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this},round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this},roundToZero:function(){return this.x=this.x<0?Math.ceil(this.x):Math.floor(this.x),this.y=this.y<0?Math.ceil(this.y):Math.floor(this.y),this},negate:function(){return this.x=-this.x,this.y=-this.y,this},dot:function(e){return this.x*e.x+this.y*e.y},lengthSq:function(){return this.x*this.x+this.y*this.y},length:function(){return Math.sqrt(this.x*this.x+this.y*this.y)},manhattanLength:function(){return Math.abs(this.x)+Math.abs(this.y)},normalize:function(){return this.divideScalar(this.length()||1)},angle:function(){var e=Math.atan2(this.y,this.x);return e<0&&(e+=2*Math.PI),e},distanceTo:function(e){return Math.sqrt(this.distanceToSquared(e))},distanceToSquared:function(e){var t=this.x-e.x,i=this.y-e.y;return t*t+i*i},manhattanDistanceTo:function(e){return Math.abs(this.x-e.x)+Math.abs(this.y-e.y)},setLength:function(e){return this.normalize().multiplyScalar(e)},lerp:function(e,t){return this.x+=(e.x-this.x)*t,this.y+=(e.y-this.y)*t,this},lerpVectors:function(e,t,i){return this.subVectors(t,e).multiplyScalar(i).add(e)},equals:function(e){return e.x===this.x&&e.y===this.y},fromArray:function(e,t){return void 0===t&&(t=0),this.x=e[t],this.y=e[t+1],this},toArray:function(e,t){return void 0===e&&(e=[]),void 0===t&&(t=0),e[t]=this.x,e[t+1]=this.y,e},fromBufferAttribute:function(e,t,i){return void 0!==i&&console.warn("THREE.Vector2: offset has been removed from .fromBufferAttribute()."),this.x=e.getX(t),this.y=e.getY(t),this},rotateAround:function(e,t){var i=Math.cos(t),n=Math.sin(t),r=this.x-e.x,a=this.y-e.y;return this.x=r*i-a*n+e.x,this.y=r*n+a*i+e.y,this}}),Object.assign(Ut.prototype,{isMatrix4:!0,set:function(e,t,i,n,r,a,o,s,c,u,h,l,d,p,f,g){var m=this.elements;return m[0]=e,m[4]=t,m[8]=i,m[12]=n,m[1]=r,m[5]=a,m[9]=o,m[13]=s,m[2]=c,m[6]=u,m[10]=h,m[14]=l,m[3]=d,m[7]=p,m[11]=f,m[15]=g,this},identity:function(){return this.set(1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1),this},clone:function(){return(new Ut).fromArray(this.elements)},copy:function(e){var t=this.elements,i=e.elements;return t[0]=i[0],t[1]=i[1],t[2]=i[2],t[3]=i[3],t[4]=i[4],t[5]=i[5],t[6]=i[6],t[7]=i[7],t[8]=i[8],t[9]=i[9],t[10]=i[10],t[11]=i[11],t[12]=i[12],t[13]=i[13],t[14]=i[14],t[15]=i[15],this},copyPosition:function(e){var t=this.elements,i=e.elements;return t[12]=i[12],t[13]=i[13],t[14]=i[14],this},extractBasis:function(e,t,i){return e.setFromMatrixColumn(this,0),t.setFromMatrixColumn(this,1),i.setFromMatrixColumn(this,2),this},makeBasis:function(e,t,i){return this.set(e.x,t.x,i.x,0,e.y,t.y,i.y,0,e.z,t.z,i.z,0,0,0,0,1),this},extractRotation:(m=new Bt,function(e){var t=this.elements,i=e.elements,n=1/m.setFromMatrixColumn(e,0).length(),r=1/m.setFromMatrixColumn(e,1).length(),a=1/m.setFromMatrixColumn(e,2).length();return t[0]=i[0]*n,t[1]=i[1]*n,t[2]=i[2]*n,t[3]=0,t[4]=i[4]*r,t[5]=i[5]*r,t[6]=i[6]*r,t[7]=0,t[8]=i[8]*a,t[9]=i[9]*a,t[10]=i[10]*a,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,this}),makeRotationFromEuler:function(e){e&&e.isEuler||console.error("THREE.Matrix4: .makeRotationFromEuler() now expects a Euler rotation rather than a Vector3 and order.");var t=this.elements,i=e.x,n=e.y,r=e.z,a=Math.cos(i),o=Math.sin(i),s=Math.cos(n),c=Math.sin(n),u=Math.cos(r),h=Math.sin(r);if("XYZ"===e.order){var l=a*u,d=a*h,p=o*u,f=o*h;t[0]=s*u,t[4]=-s*h,t[8]=c,t[1]=d+p*c,t[5]=l-f*c,t[9]=-o*s,t[2]=f-l*c,t[6]=p+d*c,t[10]=a*s}else if("YXZ"===e.order){var g=s*u,m=s*h,M=c*u,y=c*h;t[0]=g+y*o,t[4]=M*o-m,t[8]=a*c,t[1]=a*h,t[5]=a*u,t[9]=-o,t[2]=m*o-M,t[6]=y+g*o,t[10]=a*s}else if("ZXY"===e.order){g=s*u,m=s*h,M=c*u,y=c*h;t[0]=g-y*o,t[4]=-a*h,t[8]=M+m*o,t[1]=m+M*o,t[5]=a*u,t[9]=y-g*o,t[2]=-a*c,t[6]=o,t[10]=a*s}else if("ZYX"===e.order){l=a*u,d=a*h,p=o*u,f=o*h;t[0]=s*u,t[4]=p*c-d,t[8]=l*c+f,t[1]=s*h,t[5]=f*c+l,t[9]=d*c-p,t[2]=-c,t[6]=o*s,t[10]=a*s}else if("YZX"===e.order){var v=a*s,A=a*c,w=o*s,x=o*c;t[0]=s*u,t[4]=x-v*h,t[8]=w*h+A,t[1]=h,t[5]=a*u,t[9]=-o*u,t[2]=-c*u,t[6]=A*h+w,t[10]=v-x*h}else if("XZY"===e.order){v=a*s,A=a*c,w=o*s,x=o*c;t[0]=s*u,t[4]=-h,t[8]=c*u,t[1]=v*h+x,t[5]=a*u,t[9]=A*h-w,t[2]=w*h-A,t[6]=o*u,t[10]=x*h+v}return t[3]=0,t[7]=0,t[11]=0,t[12]=0,t[13]=0,t[14]=0,t[15]=1,this},makeRotationFromQuaternion:(f=new Bt(0,0,0),g=new Bt(1,1,1),function(e){return this.compose(f,e,g)}),lookAt:function(){var e=new Bt,t=new Bt,i=new Bt;return function(n,r,a){var o=this.elements;return i.subVectors(n,r),0===i.lengthSq()&&(i.z=1),i.normalize(),e.crossVectors(a,i),0===e.lengthSq()&&(1===Math.abs(a.z)?i.x+=1e-4:i.z+=1e-4,i.normalize(),e.crossVectors(a,i)),e.normalize(),t.crossVectors(i,e),o[0]=e.x,o[4]=t.x,o[8]=i.x,o[1]=e.y,o[5]=t.y,o[9]=i.y,o[2]=e.z,o[6]=t.z,o[10]=i.z,this}}(),multiply:function(e,t){return void 0!==t?(console.warn("THREE.Matrix4: .multiply() now only accepts one argument. Use .multiplyMatrices( a, b ) instead."),this.multiplyMatrices(e,t)):this.multiplyMatrices(this,e)},premultiply:function(e){return this.multiplyMatrices(e,this)},multiplyMatrices:function(e,t){var i=e.elements,n=t.elements,r=this.elements,a=i[0],o=i[4],s=i[8],c=i[12],u=i[1],h=i[5],l=i[9],d=i[13],p=i[2],f=i[6],g=i[10],m=i[14],M=i[3],y=i[7],v=i[11],A=i[15],w=n[0],x=n[4],E=n[8],T=n[12],N=n[1],D=n[5],L=n[9],b=n[13],I=n[2],_=n[6],S=n[10],j=n[14],C=n[3],O=n[7],z=n[11],R=n[15];return r[0]=a*w+o*N+s*I+c*C,r[4]=a*x+o*D+s*_+c*O,r[8]=a*E+o*L+s*S+c*z,r[12]=a*T+o*b+s*j+c*R,r[1]=u*w+h*N+l*I+d*C,r[5]=u*x+h*D+l*_+d*O,r[9]=u*E+h*L+l*S+d*z,r[13]=u*T+h*b+l*j+d*R,r[2]=p*w+f*N+g*I+m*C,r[6]=p*x+f*D+g*_+m*O,r[10]=p*E+f*L+g*S+m*z,r[14]=p*T+f*b+g*j+m*R,r[3]=M*w+y*N+v*I+A*C,r[7]=M*x+y*D+v*_+A*O,r[11]=M*E+y*L+v*S+A*z,r[15]=M*T+y*b+v*j+A*R,this},multiplyScalar:function(e){var t=this.elements;return t[0]*=e,t[4]*=e,t[8]*=e,t[12]*=e,t[1]*=e,t[5]*=e,t[9]*=e,t[13]*=e,t[2]*=e,t[6]*=e,t[10]*=e,t[14]*=e,t[3]*=e,t[7]*=e,t[11]*=e,t[15]*=e,this},applyToBufferAttribute:function(){var e=new Bt;return function(t){for(var i=0,n=t.count;i=0?1:-1,y=1-m*m;if(y>Number.EPSILON){var v=Math.sqrt(y),A=Math.atan2(v,m*M);g=Math.sin(g*A)/v,o=Math.sin(o*A)/v}var w=o*M;if(s=s*g+l*w,c=c*g+d*w,u=u*g+p*w,h=h*g+f*w,g===1-o){var x=1/Math.sqrt(s*s+c*c+u*u+h*h);s*=x,c*=x,u*=x,h*=x}}e[t]=s,e[t+1]=c,e[t+2]=u,e[t+3]=h}}),Object.defineProperties(Pt.prototype,{x:{get:function(){return this._x},set:function(e){this._x=e,this.onChangeCallback()}},y:{get:function(){return this._y},set:function(e){this._y=e,this.onChangeCallback()}},z:{get:function(){return this._z},set:function(e){this._z=e,this.onChangeCallback()}},w:{get:function(){return this._w},set:function(e){this._w=e,this.onChangeCallback()}}}),Object.assign(Pt.prototype,{set:function(e,t,i,n){return this._x=e,this._y=t,this._z=i,this._w=n,this.onChangeCallback(),this},clone:function(){return new this.constructor(this._x,this._y,this._z,this._w)},copy:function(e){return this._x=e.x,this._y=e.y,this._z=e.z,this._w=e.w,this.onChangeCallback(),this},setFromEuler:function(e,t){if(!e||!e.isEuler)throw new Error("THREE.Quaternion: .setFromEuler() now expects an Euler rotation rather than a Vector3 and order.");var i=e._x,n=e._y,r=e._z,a=e.order,o=Math.cos,s=Math.sin,c=o(i/2),u=o(n/2),h=o(r/2),l=s(i/2),d=s(n/2),p=s(r/2);return"XYZ"===a?(this._x=l*u*h+c*d*p,this._y=c*d*h-l*u*p,this._z=c*u*p+l*d*h,this._w=c*u*h-l*d*p):"YXZ"===a?(this._x=l*u*h+c*d*p,this._y=c*d*h-l*u*p,this._z=c*u*p-l*d*h,this._w=c*u*h+l*d*p):"ZXY"===a?(this._x=l*u*h-c*d*p,this._y=c*d*h+l*u*p,this._z=c*u*p+l*d*h,this._w=c*u*h-l*d*p):"ZYX"===a?(this._x=l*u*h-c*d*p,this._y=c*d*h+l*u*p,this._z=c*u*p-l*d*h,this._w=c*u*h+l*d*p):"YZX"===a?(this._x=l*u*h+c*d*p,this._y=c*d*h+l*u*p,this._z=c*u*p-l*d*h,this._w=c*u*h-l*d*p):"XZY"===a&&(this._x=l*u*h-c*d*p,this._y=c*d*h-l*u*p,this._z=c*u*p+l*d*h,this._w=c*u*h+l*d*p),!1!==t&&this.onChangeCallback(),this},setFromAxisAngle:function(e,t){var i=t/2,n=Math.sin(i);return this._x=e.x*n,this._y=e.y*n,this._z=e.z*n,this._w=Math.cos(i),this.onChangeCallback(),this},setFromRotationMatrix:function(e){var t,i=e.elements,n=i[0],r=i[4],a=i[8],o=i[1],s=i[5],c=i[9],u=i[2],h=i[6],l=i[10],d=n+s+l;return d>0?(t=.5/Math.sqrt(d+1),this._w=.25/t,this._x=(h-c)*t,this._y=(a-u)*t,this._z=(o-r)*t):n>s&&n>l?(t=2*Math.sqrt(1+n-s-l),this._w=(h-c)/t,this._x=.25*t,this._y=(r+o)/t,this._z=(a+u)/t):s>l?(t=2*Math.sqrt(1+s-n-l),this._w=(a-u)/t,this._x=(r+o)/t,this._y=.25*t,this._z=(c+h)/t):(t=2*Math.sqrt(1+l-n-s),this._w=(o-r)/t,this._x=(a+u)/t,this._y=(c+h)/t,this._z=.25*t),this.onChangeCallback(),this},setFromUnitVectors:function(){var e,t=new Bt;return function(i,n){return void 0===t&&(t=new Bt),(e=i.dot(n)+1)<1e-6?(e=0,Math.abs(i.x)>Math.abs(i.z)?t.set(-i.y,i.x,0):t.set(0,-i.z,i.y)):t.crossVectors(i,n),this._x=t.x,this._y=t.y,this._z=t.z,this._w=e,this.normalize()}}(),inverse:function(){return this.conjugate()},conjugate:function(){return this._x*=-1,this._y*=-1,this._z*=-1,this.onChangeCallback(),this},dot:function(e){return this._x*e._x+this._y*e._y+this._z*e._z+this._w*e._w},lengthSq:function(){return this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w},length:function(){return Math.sqrt(this._x*this._x+this._y*this._y+this._z*this._z+this._w*this._w)},normalize:function(){var e=this.length();return 0===e?(this._x=0,this._y=0,this._z=0,this._w=1):(e=1/e,this._x=this._x*e,this._y=this._y*e,this._z=this._z*e,this._w=this._w*e),this.onChangeCallback(),this},multiply:function(e,t){return void 0!==t?(console.warn("THREE.Quaternion: .multiply() now only accepts one argument. Use .multiplyQuaternions( a, b ) instead."),this.multiplyQuaternions(e,t)):this.multiplyQuaternions(this,e)},premultiply:function(e){return this.multiplyQuaternions(e,this)},multiplyQuaternions:function(e,t){var i=e._x,n=e._y,r=e._z,a=e._w,o=t._x,s=t._y,c=t._z,u=t._w;return this._x=i*u+a*o+n*c-r*s,this._y=n*u+a*s+r*o-i*c,this._z=r*u+a*c+i*s-n*o,this._w=a*u-i*o-n*s-r*c,this.onChangeCallback(),this},slerp:function(e,t){if(0===t)return this;if(1===t)return this.copy(e);var i=this._x,n=this._y,r=this._z,a=this._w,o=a*e._w+i*e._x+n*e._y+r*e._z;if(o<0?(this._w=-e._w,this._x=-e._x,this._y=-e._y,this._z=-e._z,o=-o):this.copy(e),o>=1)return this._w=a,this._x=i,this._y=n,this._z=r,this;var s=Math.sqrt(1-o*o);if(Math.abs(s)<.001)return this._w=.5*(a+this._w),this._x=.5*(i+this._x),this._y=.5*(n+this._y),this._z=.5*(r+this._z),this;var c=Math.atan2(s,o),u=Math.sin((1-t)*c)/s,h=Math.sin(t*c)/s;return this._w=a*u+this._w*h,this._x=i*u+this._x*h,this._y=n*u+this._y*h,this._z=r*u+this._z*h,this.onChangeCallback(),this},equals:function(e){return e._x===this._x&&e._y===this._y&&e._z===this._z&&e._w===this._w},fromArray:function(e,t){return void 0===t&&(t=0),this._x=e[t],this._y=e[t+1],this._z=e[t+2],this._w=e[t+3],this.onChangeCallback(),this},toArray:function(e,t){return void 0===e&&(e=[]),void 0===t&&(t=0),e[t]=this._x,e[t+1]=this._y,e[t+2]=this._z,e[t+3]=this._w,e},onChange:function(e){return this.onChangeCallback=e,this},onChangeCallback:function(){}}),Object.assign(Bt.prototype,{isVector3:!0,set:function(e,t,i){return this.x=e,this.y=t,this.z=i,this},setScalar:function(e){return this.x=e,this.y=e,this.z=e,this},setX:function(e){return this.x=e,this},setY:function(e){return this.y=e,this},setZ:function(e){return this.z=e,this},setComponent:function(e,t){switch(e){case 0:this.x=t;break;case 1:this.y=t;break;case 2:this.z=t;break;default:throw new Error("index is out of range: "+e)}return this},getComponent:function(e){switch(e){case 0:return this.x;case 1:return this.y;case 2:return this.z;default:throw new Error("index is out of range: "+e)}},clone:function(){return new this.constructor(this.x,this.y,this.z)},copy:function(e){return this.x=e.x,this.y=e.y,this.z=e.z,this},add:function(e,t){return void 0!==t?(console.warn("THREE.Vector3: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(e,t)):(this.x+=e.x,this.y+=e.y,this.z+=e.z,this)},addScalar:function(e){return this.x+=e,this.y+=e,this.z+=e,this},addVectors:function(e,t){return this.x=e.x+t.x,this.y=e.y+t.y,this.z=e.z+t.z,this},addScaledVector:function(e,t){return this.x+=e.x*t,this.y+=e.y*t,this.z+=e.z*t,this},sub:function(e,t){return void 0!==t?(console.warn("THREE.Vector3: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(e,t)):(this.x-=e.x,this.y-=e.y,this.z-=e.z,this)},subScalar:function(e){return this.x-=e,this.y-=e,this.z-=e,this},subVectors:function(e,t){return this.x=e.x-t.x,this.y=e.y-t.y,this.z=e.z-t.z,this},multiply:function(e,t){return void 0!==t?(console.warn("THREE.Vector3: .multiply() now only accepts one argument. Use .multiplyVectors( a, b ) instead."),this.multiplyVectors(e,t)):(this.x*=e.x,this.y*=e.y,this.z*=e.z,this)},multiplyScalar:function(e){return this.x*=e,this.y*=e,this.z*=e,this},multiplyVectors:function(e,t){return this.x=e.x*t.x,this.y=e.y*t.y,this.z=e.z*t.z,this},applyEuler:(M=new Pt,function(e){return e&&e.isEuler||console.error("THREE.Vector3: .applyEuler() now expects an Euler rotation rather than a Vector3 and order."),this.applyQuaternion(M.setFromEuler(e))}),applyAxisAngle:function(){var e=new Pt;return function(t,i){return this.applyQuaternion(e.setFromAxisAngle(t,i))}}(),applyMatrix3:function(e){var t=this.x,i=this.y,n=this.z,r=e.elements;return this.x=r[0]*t+r[3]*i+r[6]*n,this.y=r[1]*t+r[4]*i+r[7]*n,this.z=r[2]*t+r[5]*i+r[8]*n,this},applyMatrix4:function(e){var t=this.x,i=this.y,n=this.z,r=e.elements,a=1/(r[3]*t+r[7]*i+r[11]*n+r[15]);return this.x=(r[0]*t+r[4]*i+r[8]*n+r[12])*a,this.y=(r[1]*t+r[5]*i+r[9]*n+r[13])*a,this.z=(r[2]*t+r[6]*i+r[10]*n+r[14])*a,this},applyQuaternion:function(e){var t=this.x,i=this.y,n=this.z,r=e.x,a=e.y,o=e.z,s=e.w,c=s*t+a*n-o*i,u=s*i+o*t-r*n,h=s*n+r*i-a*t,l=-r*t-a*i-o*n;return this.x=c*s+l*-r+u*-o-h*-a,this.y=u*s+l*-a+h*-r-c*-o,this.z=h*s+l*-o+c*-a-u*-r,this},project:function(){var e=new Ut;return function(t){return e.multiplyMatrices(t.projectionMatrix,e.getInverse(t.matrixWorld)),this.applyMatrix4(e)}}(),unproject:function(){var e=new Ut;return function(t){return e.multiplyMatrices(t.matrixWorld,e.getInverse(t.projectionMatrix)),this.applyMatrix4(e)}}(),transformDirection:function(e){var t=this.x,i=this.y,n=this.z,r=e.elements;return this.x=r[0]*t+r[4]*i+r[8]*n,this.y=r[1]*t+r[5]*i+r[9]*n,this.z=r[2]*t+r[6]*i+r[10]*n,this.normalize()},divide:function(e){return this.x/=e.x,this.y/=e.y,this.z/=e.z,this},divideScalar:function(e){return this.multiplyScalar(1/e)},min:function(e){return this.x=Math.min(this.x,e.x),this.y=Math.min(this.y,e.y),this.z=Math.min(this.z,e.z),this},max:function(e){return this.x=Math.max(this.x,e.x),this.y=Math.max(this.y,e.y),this.z=Math.max(this.z,e.z),this},clamp:function(e,t){return this.x=Math.max(e.x,Math.min(t.x,this.x)),this.y=Math.max(e.y,Math.min(t.y,this.y)),this.z=Math.max(e.z,Math.min(t.z,this.z)),this},clampScalar:function(){var e=new Bt,t=new Bt;return function(i,n){return e.set(i,i,i),t.set(n,n,n),this.clamp(e,t)}}(),clampLength:function(e,t){var i=this.length();return this.divideScalar(i||1).multiplyScalar(Math.max(e,Math.min(t,i)))},floor:function(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this},ceil:function(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this},round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this},roundToZero:function(){return this.x=this.x<0?Math.ceil(this.x):Math.floor(this.x),this.y=this.y<0?Math.ceil(this.y):Math.floor(this.y),this.z=this.z<0?Math.ceil(this.z):Math.floor(this.z),this},negate:function(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this},dot:function(e){return this.x*e.x+this.y*e.y+this.z*e.z},lengthSq:function(){return this.x*this.x+this.y*this.y+this.z*this.z},length:function(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z)},manhattanLength:function(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)},normalize:function(){return this.divideScalar(this.length()||1)},setLength:function(e){return this.normalize().multiplyScalar(e)},lerp:function(e,t){return this.x+=(e.x-this.x)*t,this.y+=(e.y-this.y)*t,this.z+=(e.z-this.z)*t,this},lerpVectors:function(e,t,i){return this.subVectors(t,e).multiplyScalar(i).add(e)},cross:function(e,t){return void 0!==t?(console.warn("THREE.Vector3: .cross() now only accepts one argument. Use .crossVectors( a, b ) instead."),this.crossVectors(e,t)):this.crossVectors(this,e)},crossVectors:function(e,t){var i=e.x,n=e.y,r=e.z,a=t.x,o=t.y,s=t.z;return this.x=n*s-r*o,this.y=r*a-i*s,this.z=i*o-n*a,this},projectOnVector:function(e){var t=e.dot(this)/e.lengthSq();return this.copy(e).multiplyScalar(t)},projectOnPlane:function(){var e=new Bt;return function(t){return e.copy(this).projectOnVector(t),this.sub(e)}}(),reflect:function(){var e=new Bt;return function(t){return this.sub(e.copy(t).multiplyScalar(2*this.dot(t)))}}(),angleTo:function(e){var t=this.dot(e)/Math.sqrt(this.lengthSq()*e.lengthSq());return Math.acos(zt.clamp(t,-1,1))},distanceTo:function(e){return Math.sqrt(this.distanceToSquared(e))},distanceToSquared:function(e){var t=this.x-e.x,i=this.y-e.y,n=this.z-e.z;return t*t+i*i+n*n},manhattanDistanceTo:function(e){return Math.abs(this.x-e.x)+Math.abs(this.y-e.y)+Math.abs(this.z-e.z)},setFromSpherical:function(e){var t=Math.sin(e.phi)*e.radius;return this.x=t*Math.sin(e.theta),this.y=Math.cos(e.phi)*e.radius,this.z=t*Math.cos(e.theta),this},setFromCylindrical:function(e){return this.x=e.radius*Math.sin(e.theta),this.y=e.y,this.z=e.radius*Math.cos(e.theta),this},setFromMatrixPosition:function(e){var t=e.elements;return this.x=t[12],this.y=t[13],this.z=t[14],this},setFromMatrixScale:function(e){var t=this.setFromMatrixColumn(e,0).length(),i=this.setFromMatrixColumn(e,1).length(),n=this.setFromMatrixColumn(e,2).length();return this.x=t,this.y=i,this.z=n,this},setFromMatrixColumn:function(e,t){return this.fromArray(e.elements,4*t)},equals:function(e){return e.x===this.x&&e.y===this.y&&e.z===this.z},fromArray:function(e,t){return void 0===t&&(t=0),this.x=e[t],this.y=e[t+1],this.z=e[t+2],this},toArray:function(e,t){return void 0===e&&(e=[]),void 0===t&&(t=0),e[t]=this.x,e[t+1]=this.y,e[t+2]=this.z,e},fromBufferAttribute:function(e,t,i){return void 0!==i&&console.warn("THREE.Vector3: offset has been removed from .fromBufferAttribute()."),this.x=e.getX(t),this.y=e.getY(t),this.z=e.getZ(t),this}}),Object.assign(kt.prototype,{isMatrix3:!0,set:function(e,t,i,n,r,a,o,s,c){var u=this.elements;return u[0]=e,u[1]=n,u[2]=o,u[3]=t,u[4]=r,u[5]=s,u[6]=i,u[7]=a,u[8]=c,this},identity:function(){return this.set(1,0,0,0,1,0,0,0,1),this},clone:function(){return(new this.constructor).fromArray(this.elements)},copy:function(e){var t=this.elements,i=e.elements;return t[0]=i[0],t[1]=i[1],t[2]=i[2],t[3]=i[3],t[4]=i[4],t[5]=i[5],t[6]=i[6],t[7]=i[7],t[8]=i[8],this},setFromMatrix4:function(e){var t=e.elements;return this.set(t[0],t[4],t[8],t[1],t[5],t[9],t[2],t[6],t[10]),this},applyToBufferAttribute:function(){var e=new Bt;return function(t){for(var i=0,n=t.count;i2048||t.height>2048?t.toDataURL("image/jpeg",.6):t.toDataURL("image/png")}(n)}),i.image=n.uuid}return t||(e.textures[this.uuid]=i),i},dispose:function(){this.dispatchEvent({type:"dispose"})},transformUv:function(e){if(300===this.mapping){if(e.applyMatrix3(this.matrix),e.x<0||e.x>1)switch(this.wrapS){case Te:e.x=e.x-Math.floor(e.x);break;case Ne:e.x=e.x<0?0:1;break;case De:1===Math.abs(Math.floor(e.x)%2)?e.x=Math.ceil(e.x)-e.x:e.x=e.x-Math.floor(e.x)}if(e.y<0||e.y>1)switch(this.wrapT){case Te:e.y=e.y-Math.floor(e.y);break;case Ne:e.y=e.y<0?0:1;break;case De:1===Math.abs(Math.floor(e.y)%2)?e.y=Math.ceil(e.y)-e.y:e.y=e.y-Math.floor(e.y)}this.flipY&&(e.y=1-e.y)}}}),Object.defineProperty(Wt.prototype,"needsUpdate",{set:function(e){!0===e&&this.version++}}),Object.assign(Xt.prototype,{isVector4:!0,set:function(e,t,i,n){return this.x=e,this.y=t,this.z=i,this.w=n,this},setScalar:function(e){return this.x=e,this.y=e,this.z=e,this.w=e,this},setX:function(e){return this.x=e,this},setY:function(e){return this.y=e,this},setZ:function(e){return this.z=e,this},setW:function(e){return this.w=e,this},setComponent:function(e,t){switch(e){case 0:this.x=t;break;case 1:this.y=t;break;case 2:this.z=t;break;case 3:this.w=t;break;default:throw new Error("index is out of range: "+e)}return this},getComponent:function(e){switch(e){case 0:return this.x;case 1:return this.y;case 2:return this.z;case 3:return this.w;default:throw new Error("index is out of range: "+e)}},clone:function(){return new this.constructor(this.x,this.y,this.z,this.w)},copy:function(e){return this.x=e.x,this.y=e.y,this.z=e.z,this.w=void 0!==e.w?e.w:1,this},add:function(e,t){return void 0!==t?(console.warn("THREE.Vector4: .add() now only accepts one argument. Use .addVectors( a, b ) instead."),this.addVectors(e,t)):(this.x+=e.x,this.y+=e.y,this.z+=e.z,this.w+=e.w,this)},addScalar:function(e){return this.x+=e,this.y+=e,this.z+=e,this.w+=e,this},addVectors:function(e,t){return this.x=e.x+t.x,this.y=e.y+t.y,this.z=e.z+t.z,this.w=e.w+t.w,this},addScaledVector:function(e,t){return this.x+=e.x*t,this.y+=e.y*t,this.z+=e.z*t,this.w+=e.w*t,this},sub:function(e,t){return void 0!==t?(console.warn("THREE.Vector4: .sub() now only accepts one argument. Use .subVectors( a, b ) instead."),this.subVectors(e,t)):(this.x-=e.x,this.y-=e.y,this.z-=e.z,this.w-=e.w,this)},subScalar:function(e){return this.x-=e,this.y-=e,this.z-=e,this.w-=e,this},subVectors:function(e,t){return this.x=e.x-t.x,this.y=e.y-t.y,this.z=e.z-t.z,this.w=e.w-t.w,this},multiplyScalar:function(e){return this.x*=e,this.y*=e,this.z*=e,this.w*=e,this},applyMatrix4:function(e){var t=this.x,i=this.y,n=this.z,r=this.w,a=e.elements;return this.x=a[0]*t+a[4]*i+a[8]*n+a[12]*r,this.y=a[1]*t+a[5]*i+a[9]*n+a[13]*r,this.z=a[2]*t+a[6]*i+a[10]*n+a[14]*r,this.w=a[3]*t+a[7]*i+a[11]*n+a[15]*r,this},divideScalar:function(e){return this.multiplyScalar(1/e)},setAxisAngleFromQuaternion:function(e){this.w=2*Math.acos(e.w);var t=Math.sqrt(1-e.w*e.w);return t<1e-4?(this.x=1,this.y=0,this.z=0):(this.x=e.x/t,this.y=e.y/t,this.z=e.z/t),this},setAxisAngleFromRotationMatrix:function(e){var t,i,n,r,a=e.elements,o=a[0],s=a[4],c=a[8],u=a[1],h=a[5],l=a[9],d=a[2],p=a[6],f=a[10];if(Math.abs(s-u)<.01&&Math.abs(c-d)<.01&&Math.abs(l-p)<.01){if(Math.abs(s+u)<.1&&Math.abs(c+d)<.1&&Math.abs(l+p)<.1&&Math.abs(o+h+f-3)<.1)return this.set(1,0,0,0),this;t=Math.PI;var g=(o+1)/2,m=(h+1)/2,M=(f+1)/2,y=(s+u)/4,v=(c+d)/4,A=(l+p)/4;return g>m&&g>M?g<.01?(i=0,n=.707106781,r=.707106781):(n=y/(i=Math.sqrt(g)),r=v/i):m>M?m<.01?(i=.707106781,n=0,r=.707106781):(i=y/(n=Math.sqrt(m)),r=A/n):M<.01?(i=.707106781,n=.707106781,r=0):(i=v/(r=Math.sqrt(M)),n=A/r),this.set(i,n,r,t),this}var w=Math.sqrt((p-l)*(p-l)+(c-d)*(c-d)+(u-s)*(u-s));return Math.abs(w)<.001&&(w=1),this.x=(p-l)/w,this.y=(c-d)/w,this.z=(u-s)/w,this.w=Math.acos((o+h+f-1)/2),this},min:function(e){return this.x=Math.min(this.x,e.x),this.y=Math.min(this.y,e.y),this.z=Math.min(this.z,e.z),this.w=Math.min(this.w,e.w),this},max:function(e){return this.x=Math.max(this.x,e.x),this.y=Math.max(this.y,e.y),this.z=Math.max(this.z,e.z),this.w=Math.max(this.w,e.w),this},clamp:function(e,t){return this.x=Math.max(e.x,Math.min(t.x,this.x)),this.y=Math.max(e.y,Math.min(t.y,this.y)),this.z=Math.max(e.z,Math.min(t.z,this.z)),this.w=Math.max(e.w,Math.min(t.w,this.w)),this},clampScalar:function(){var e,t;return function(i,n){return void 0===e&&(e=new Xt,t=new Xt),e.set(i,i,i,i),t.set(n,n,n,n),this.clamp(e,t)}}(),clampLength:function(e,t){var i=this.length();return this.divideScalar(i||1).multiplyScalar(Math.max(e,Math.min(t,i)))},floor:function(){return this.x=Math.floor(this.x),this.y=Math.floor(this.y),this.z=Math.floor(this.z),this.w=Math.floor(this.w),this},ceil:function(){return this.x=Math.ceil(this.x),this.y=Math.ceil(this.y),this.z=Math.ceil(this.z),this.w=Math.ceil(this.w),this},round:function(){return this.x=Math.round(this.x),this.y=Math.round(this.y),this.z=Math.round(this.z),this.w=Math.round(this.w),this},roundToZero:function(){return this.x=this.x<0?Math.ceil(this.x):Math.floor(this.x),this.y=this.y<0?Math.ceil(this.y):Math.floor(this.y),this.z=this.z<0?Math.ceil(this.z):Math.floor(this.z),this.w=this.w<0?Math.ceil(this.w):Math.floor(this.w),this},negate:function(){return this.x=-this.x,this.y=-this.y,this.z=-this.z,this.w=-this.w,this},dot:function(e){return this.x*e.x+this.y*e.y+this.z*e.z+this.w*e.w},lengthSq:function(){return this.x*this.x+this.y*this.y+this.z*this.z+this.w*this.w},length:function(){return Math.sqrt(this.x*this.x+this.y*this.y+this.z*this.z+this.w*this.w)},manhattanLength:function(){return Math.abs(this.x)+Math.abs(this.y)+Math.abs(this.z)+Math.abs(this.w)},normalize:function(){return this.divideScalar(this.length()||1)},setLength:function(e){return this.normalize().multiplyScalar(e)},lerp:function(e,t){return this.x+=(e.x-this.x)*t,this.y+=(e.y-this.y)*t,this.z+=(e.z-this.z)*t,this.w+=(e.w-this.w)*t,this},lerpVectors:function(e,t,i){return this.subVectors(t,e).multiplyScalar(i).add(e)},equals:function(e){return e.x===this.x&&e.y===this.y&&e.z===this.z&&e.w===this.w},fromArray:function(e,t){return void 0===t&&(t=0),this.x=e[t],this.y=e[t+1],this.z=e[t+2],this.w=e[t+3],this},toArray:function(e,t){return void 0===e&&(e=[]),void 0===t&&(t=0),e[t]=this.x,e[t+1]=this.y,e[t+2]=this.z,e[t+3]=this.w,e},fromBufferAttribute:function(e,t,i){return void 0!==i&&console.warn("THREE.Vector4: offset has been removed from .fromBufferAttribute()."),this.x=e.getX(t),this.y=e.getY(t),this.z=e.getZ(t),this.w=e.getW(t),this}}),Zt.prototype=Object.assign(Object.create(u.prototype),{constructor:Zt,isWebGLRenderTarget:!0,setSize:function(e,t){this.width===e&&this.height===t||(this.width=e,this.height=t,this.dispose()),this.viewport.set(0,0,e,t),this.scissor.set(0,0,e,t)},clone:function(){return(new this.constructor).copy(this)},copy:function(e){return this.width=e.width,this.height=e.height,this.viewport.copy(e.viewport),this.texture=e.texture.clone(),this.depthBuffer=e.depthBuffer,this.stencilBuffer=e.stencilBuffer,this.depthTexture=e.depthTexture,this},dispose:function(){this.dispatchEvent({type:"dispose"})}}),qt.prototype=Object.create(Zt.prototype),qt.prototype.constructor=qt,qt.prototype.isWebGLRenderTargetCube=!0,Jt.prototype=Object.create(Wt.prototype),Jt.prototype.constructor=Jt,Jt.prototype.isDataTexture=!0,Object.assign(Kt.prototype,{isBox3:!0,set:function(e,t){return this.min.copy(e),this.max.copy(t),this},setFromArray:function(e){for(var t=1/0,i=1/0,n=1/0,r=-1/0,a=-1/0,o=-1/0,s=0,c=e.length;sr&&(r=u),h>a&&(a=h),l>o&&(o=l)}return this.min.set(t,i,n),this.max.set(r,a,o),this},setFromBufferAttribute:function(e){for(var t=1/0,i=1/0,n=1/0,r=-1/0,a=-1/0,o=-1/0,s=0,c=e.count;sr&&(r=u),h>a&&(a=h),l>o&&(o=l)}return this.min.set(t,i,n),this.max.set(r,a,o),this},setFromPoints:function(e){this.makeEmpty();for(var t=0,i=e.length;tthis.max.x||e.ythis.max.y||e.zthis.max.z)},containsBox:function(e){return this.min.x<=e.min.x&&e.max.x<=this.max.x&&this.min.y<=e.min.y&&e.max.y<=this.max.y&&this.min.z<=e.min.z&&e.max.z<=this.max.z},getParameter:function(e,t){return void 0===t&&(console.warn("THREE.Box3: .getParameter() target is now required"),t=new Bt),t.set((e.x-this.min.x)/(this.max.x-this.min.x),(e.y-this.min.y)/(this.max.y-this.min.y),(e.z-this.min.z)/(this.max.z-this.min.z))},intersectsBox:function(e){return!(e.max.xthis.max.x||e.max.ythis.max.y||e.max.zthis.max.z)},intersectsSphere:(Ft=new Bt,function(e){return this.clampPoint(e.center,Ft),Ft.distanceToSquared(e.center)<=e.radius*e.radius}),intersectsPlane:function(e){var t,i;return e.normal.x>0?(t=e.normal.x*this.min.x,i=e.normal.x*this.max.x):(t=e.normal.x*this.max.x,i=e.normal.x*this.min.x),e.normal.y>0?(t+=e.normal.y*this.min.y,i+=e.normal.y*this.max.y):(t+=e.normal.y*this.max.y,i+=e.normal.y*this.min.y),e.normal.z>0?(t+=e.normal.z*this.min.z,i+=e.normal.z*this.max.z):(t+=e.normal.z*this.max.z,i+=e.normal.z*this.min.z),t<=e.constant&&i>=e.constant},intersectsTriangle:function(){var e=new Bt,t=new Bt,i=new Bt,n=new Bt,r=new Bt,a=new Bt,o=new Bt,s=new Bt,c=new Bt,u=new Bt;function h(n){var r,a;for(r=0,a=n.length-3;r<=a;r+=3){o.fromArray(n,r);var s=c.x*Math.abs(o.x)+c.y*Math.abs(o.y)+c.z*Math.abs(o.z),u=e.dot(o),h=t.dot(o),l=i.dot(o);if(Math.max(-Math.max(u,h,l),Math.min(u,h,l))>s)return!1}return!0}return function(o){if(this.isEmpty())return!1;this.getCenter(s),c.subVectors(this.max,s),e.subVectors(o.a,s),t.subVectors(o.b,s),i.subVectors(o.c,s),n.subVectors(t,e),r.subVectors(i,t),a.subVectors(e,i);var l=[0,-n.z,n.y,0,-r.z,r.y,0,-a.z,a.y,n.z,0,-n.x,r.z,0,-r.x,a.z,0,-a.x,-n.y,n.x,0,-r.y,r.x,0,-a.y,a.x,0];return!!h(l)&&(!!h(l=[1,0,0,0,1,0,0,0,1])&&(u.crossVectors(n,r),h(l=[u.x,u.y,u.z])))}}(),clampPoint:function(e,t){return void 0===t&&(console.warn("THREE.Box3: .clampPoint() target is now required"),t=new Bt),t.copy(e).clamp(this.min,this.max)},distanceToPoint:function(){var e=new Bt;return function(t){return e.copy(t).clamp(this.min,this.max).sub(t).length()}}(),getBoundingSphere:function(){var e=new Bt;return function(t){return void 0===t&&(console.warn("THREE.Box3: .getBoundingSphere() target is now required"),t=new $t),this.getCenter(t.center),t.radius=.5*this.getSize(e).length(),t}}(),intersect:function(e){return this.min.max(e.min),this.max.min(e.max),this.isEmpty()&&this.makeEmpty(),this},union:function(e){return this.min.min(e.min),this.max.max(e.max),this},applyMatrix4:function(e){if(this.isEmpty())return this;var t=e.elements,i=t[0]*this.min.x,n=t[1]*this.min.x,r=t[2]*this.min.x,a=t[0]*this.max.x,o=t[1]*this.max.x,s=t[2]*this.max.x,c=t[4]*this.min.y,u=t[5]*this.min.y,h=t[6]*this.min.y,l=t[4]*this.max.y,d=t[5]*this.max.y,p=t[6]*this.max.y,f=t[8]*this.min.z,g=t[9]*this.min.z,m=t[10]*this.min.z,M=t[8]*this.max.z,y=t[9]*this.max.z,v=t[10]*this.max.z;return this.min.x=Math.min(i,a)+Math.min(c,l)+Math.min(f,M)+t[12],this.min.y=Math.min(n,o)+Math.min(u,d)+Math.min(g,y)+t[13],this.min.z=Math.min(r,s)+Math.min(h,p)+Math.min(m,v)+t[14],this.max.x=Math.max(i,a)+Math.max(c,l)+Math.max(f,M)+t[12],this.max.y=Math.max(n,o)+Math.max(u,d)+Math.max(g,y)+t[13],this.max.z=Math.max(r,s)+Math.max(h,p)+Math.max(m,v)+t[14],this},translate:function(e){return this.min.add(e),this.max.add(e),this},equals:function(e){return e.min.equals(this.min)&&e.max.equals(this.max)}}),Object.assign($t.prototype,{set:function(e,t){return this.center.copy(e),this.radius=t,this},setFromPoints:(Gt=new Kt,function(e,t){var i=this.center;void 0!==t?i.copy(t):Gt.setFromPoints(e).getCenter(i);for(var n=0,r=0,a=e.length;rthis.radius*this.radius&&(t.sub(this.center).normalize(),t.multiplyScalar(this.radius).add(this.center)),t},getBoundingBox:function(e){return void 0===e&&(console.warn("THREE.Sphere: .getBoundingBox() target is now required"),e=new Kt),e.set(this.center,this.center),e.expandByScalar(this.radius),e},applyMatrix4:function(e){return this.center.applyMatrix4(e),this.radius=this.radius*e.getMaxScaleOnAxis(),this},translate:function(e){return this.center.add(e),this},equals:function(e){return e.center.equals(this.center)&&e.radius===this.radius}}),Object.assign(ei.prototype,{set:function(e,t){return this.normal.copy(e),this.constant=t,this},setComponents:function(e,t,i,n){return this.normal.set(e,t,i),this.constant=n,this},setFromNormalAndCoplanarPoint:function(e,t){return this.normal.copy(e),this.constant=-t.dot(this.normal),this},setFromCoplanarPoints:function(){var e=new Bt,t=new Bt;return function(i,n,r){var a=e.subVectors(r,n).cross(t.subVectors(i,n)).normalize();return this.setFromNormalAndCoplanarPoint(a,i),this}}(),clone:function(){return(new this.constructor).copy(this)},copy:function(e){return this.normal.copy(e.normal),this.constant=e.constant,this},normalize:function(){var e=1/this.normal.length();return this.normal.multiplyScalar(e),this.constant*=e,this},negate:function(){return this.constant*=-1,this.normal.negate(),this},distanceToPoint:function(e){return this.normal.dot(e)+this.constant},distanceToSphere:function(e){return this.distanceToPoint(e.center)-e.radius},projectPoint:function(e,t){return void 0===t&&(console.warn("THREE.Plane: .projectPoint() target is now required"),t=new Bt),t.copy(this.normal).multiplyScalar(-this.distanceToPoint(e)).add(e)},intersectLine:function(){var e=new Bt;return function(t,i){void 0===i&&(console.warn("THREE.Plane: .intersectLine() target is now required"),i=new Bt);var n=t.delta(e),r=this.normal.dot(n);if(0===r)return 0===this.distanceToPoint(t.start)?i.copy(t.start):void 0;var a=-(t.start.dot(this.normal)+this.constant)/r;return a<0||a>1?void 0:i.copy(n).multiplyScalar(a).add(t.start)}}(),intersectsLine:function(e){var t=this.distanceToPoint(e.start),i=this.distanceToPoint(e.end);return t<0&&i>0||i<0&&t>0},intersectsBox:function(e){return e.intersectsPlane(this)},intersectsSphere:function(e){return e.intersectsPlane(this)},coplanarPoint:function(e){return void 0===e&&(console.warn("THREE.Plane: .coplanarPoint() target is now required"),e=new Bt),e.copy(this.normal).multiplyScalar(-this.constant)},applyMatrix4:function(){var e=new Bt,t=new kt;return function(i,n){var r=n||t.getNormalMatrix(i),a=this.coplanarPoint(e).applyMatrix4(i),o=this.normal.applyMatrix3(r).normalize();return this.constant=-a.dot(o),this}}(),translate:function(e){return this.constant-=e.dot(this.normal),this},equals:function(e){return e.normal.equals(this.normal)&&e.constant===this.constant}}),Object.assign(ti.prototype,{set:function(e,t,i,n,r,a){var o=this.planes;return o[0].copy(e),o[1].copy(t),o[2].copy(i),o[3].copy(n),o[4].copy(r),o[5].copy(a),this},clone:function(){return(new this.constructor).copy(this)},copy:function(e){for(var t=this.planes,i=0;i<6;i++)t[i].copy(e.planes[i]);return this},setFromMatrix:function(e){var t=this.planes,i=e.elements,n=i[0],r=i[1],a=i[2],o=i[3],s=i[4],c=i[5],u=i[6],h=i[7],l=i[8],d=i[9],p=i[10],f=i[11],g=i[12],m=i[13],M=i[14],y=i[15];return t[0].setComponents(o-n,h-s,f-l,y-g).normalize(),t[1].setComponents(o+n,h+s,f+l,y+g).normalize(),t[2].setComponents(o+r,h+c,f+d,y+m).normalize(),t[3].setComponents(o-r,h-c,f-d,y-m).normalize(),t[4].setComponents(o-a,h-u,f-p,y-M).normalize(),t[5].setComponents(o+a,h+u,f+p,y+M).normalize(),this},intersectsObject:(Vt=new $t,function(e){var t=e.geometry;return null===t.boundingSphere&&t.computeBoundingSphere(),Vt.copy(t.boundingSphere).applyMatrix4(e.matrixWorld),this.intersectsSphere(Vt)}),intersectsSprite:function(){var e=new $t;return function(t){return e.center.set(0,0,0),e.radius=.7071067811865476,e.applyMatrix4(t.matrixWorld),this.intersectsSphere(e)}}(),intersectsSphere:function(e){for(var t=this.planes,i=e.center,n=-e.radius,r=0;r<6;r++){if(t[r].distanceToPoint(i)0?e.min.x:e.max.x,Yt.x=n.normal.x>0?e.max.x:e.min.x,Qt.y=n.normal.y>0?e.min.y:e.max.y,Yt.y=n.normal.y>0?e.max.y:e.min.y,Qt.z=n.normal.z>0?e.min.z:e.max.z,Yt.z=n.normal.z>0?e.max.z:e.min.z;var r=n.distanceToPoint(Qt),a=n.distanceToPoint(Yt);if(r<0&&a<0)return!1}return!0}),containsPoint:function(e){for(var t=this.planes,i=0;i<6;i++)if(t[i].distanceToPoint(e)<0)return!1;return!0}});var ii,ni={alphamap_fragment:"#ifdef USE_ALPHAMAP\n\tdiffuseColor.a *= texture2D( alphaMap, vUv ).g;\n#endif\n",alphamap_pars_fragment:"#ifdef USE_ALPHAMAP\n\tuniform sampler2D alphaMap;\n#endif\n",alphatest_fragment:"#ifdef ALPHATEST\n\tif ( diffuseColor.a < ALPHATEST ) discard;\n#endif\n",aomap_fragment:"#ifdef USE_AOMAP\n\tfloat ambientOcclusion = ( texture2D( aoMap, vUv2 ).r - 1.0 ) * aoMapIntensity + 1.0;\n\treflectedLight.indirectDiffuse *= ambientOcclusion;\n\t#if defined( USE_ENVMAP ) && defined( PHYSICAL )\n\t\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\t\treflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, ambientOcclusion, material.specularRoughness );\n\t#endif\n#endif\n",aomap_pars_fragment:"#ifdef USE_AOMAP\n\tuniform sampler2D aoMap;\n\tuniform float aoMapIntensity;\n#endif",begin_vertex:"\nvec3 transformed = vec3( position );\n",beginnormal_vertex:"\nvec3 objectNormal = vec3( normal );\n",bsdfs:"float punctualLightIntensityToIrradianceFactor( const in float lightDistance, const in float cutoffDistance, const in float decayExponent ) {\n\tif( decayExponent > 0.0 ) {\n#if defined ( PHYSICALLY_CORRECT_LIGHTS )\n\t\tfloat distanceFalloff = 1.0 / max( pow( lightDistance, decayExponent ), 0.01 );\n\t\tfloat maxDistanceCutoffFactor = pow2( saturate( 1.0 - pow4( lightDistance / cutoffDistance ) ) );\n\t\treturn distanceFalloff * maxDistanceCutoffFactor;\n#else\n\t\treturn pow( saturate( -lightDistance / cutoffDistance + 1.0 ), decayExponent );\n#endif\n\t}\n\treturn 1.0;\n}\nvec3 BRDF_Diffuse_Lambert( const in vec3 diffuseColor ) {\n\treturn RECIPROCAL_PI * diffuseColor;\n}\nvec3 F_Schlick( const in vec3 specularColor, const in float dotLH ) {\n\tfloat fresnel = exp2( ( -5.55473 * dotLH - 6.98316 ) * dotLH );\n\treturn ( 1.0 - specularColor ) * fresnel + specularColor;\n}\nfloat G_GGX_Smith( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gl = dotNL + sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\tfloat gv = dotNV + sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\treturn 1.0 / ( gl * gv );\n}\nfloat G_GGX_SmithCorrelated( const in float alpha, const in float dotNL, const in float dotNV ) {\n\tfloat a2 = pow2( alpha );\n\tfloat gv = dotNL * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNV ) );\n\tfloat gl = dotNV * sqrt( a2 + ( 1.0 - a2 ) * pow2( dotNL ) );\n\treturn 0.5 / max( gv + gl, EPSILON );\n}\nfloat D_GGX( const in float alpha, const in float dotNH ) {\n\tfloat a2 = pow2( alpha );\n\tfloat denom = pow2( dotNH ) * ( a2 - 1.0 ) + 1.0;\n\treturn RECIPROCAL_PI * a2 / pow2( denom );\n}\nvec3 BRDF_Specular_GGX( const in IncidentLight incidentLight, const in GeometricContext geometry, const in vec3 specularColor, const in float roughness ) {\n\tfloat alpha = pow2( roughness );\n\tvec3 halfDir = normalize( incidentLight.direction + geometry.viewDir );\n\tfloat dotNL = saturate( dot( geometry.normal, incidentLight.direction ) );\n\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\tfloat dotNH = saturate( dot( geometry.normal, halfDir ) );\n\tfloat dotLH = saturate( dot( incidentLight.direction, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, dotLH );\n\tfloat G = G_GGX_SmithCorrelated( alpha, dotNL, dotNV );\n\tfloat D = D_GGX( alpha, dotNH );\n\treturn F * ( G * D );\n}\nvec2 LTC_Uv( const in vec3 N, const in vec3 V, const in float roughness ) {\n\tconst float LUT_SIZE = 64.0;\n\tconst float LUT_SCALE = ( LUT_SIZE - 1.0 ) / LUT_SIZE;\n\tconst float LUT_BIAS = 0.5 / LUT_SIZE;\n\tfloat dotNV = saturate( dot( N, V ) );\n\tvec2 uv = vec2( roughness, sqrt( 1.0 - dotNV ) );\n\tuv = uv * LUT_SCALE + LUT_BIAS;\n\treturn uv;\n}\nfloat LTC_ClippedSphereFormFactor( const in vec3 f ) {\n\tfloat l = length( f );\n\treturn max( ( l * l + f.z ) / ( l + 1.0 ), 0.0 );\n}\nvec3 LTC_EdgeVectorFormFactor( const in vec3 v1, const in vec3 v2 ) {\n\tfloat x = dot( v1, v2 );\n\tfloat y = abs( x );\n\tfloat a = 0.8543985 + ( 0.4965155 + 0.0145206 * y ) * y;\n\tfloat b = 3.4175940 + ( 4.1616724 + y ) * y;\n\tfloat v = a / b;\n\tfloat theta_sintheta = ( x > 0.0 ) ? v : 0.5 * inversesqrt( max( 1.0 - x * x, 1e-7 ) ) - v;\n\treturn cross( v1, v2 ) * theta_sintheta;\n}\nvec3 LTC_Evaluate( const in vec3 N, const in vec3 V, const in vec3 P, const in mat3 mInv, const in vec3 rectCoords[ 4 ] ) {\n\tvec3 v1 = rectCoords[ 1 ] - rectCoords[ 0 ];\n\tvec3 v2 = rectCoords[ 3 ] - rectCoords[ 0 ];\n\tvec3 lightNormal = cross( v1, v2 );\n\tif( dot( lightNormal, P - rectCoords[ 0 ] ) < 0.0 ) return vec3( 0.0 );\n\tvec3 T1, T2;\n\tT1 = normalize( V - N * dot( V, N ) );\n\tT2 = - cross( N, T1 );\n\tmat3 mat = mInv * transposeMat3( mat3( T1, T2, N ) );\n\tvec3 coords[ 4 ];\n\tcoords[ 0 ] = mat * ( rectCoords[ 0 ] - P );\n\tcoords[ 1 ] = mat * ( rectCoords[ 1 ] - P );\n\tcoords[ 2 ] = mat * ( rectCoords[ 2 ] - P );\n\tcoords[ 3 ] = mat * ( rectCoords[ 3 ] - P );\n\tcoords[ 0 ] = normalize( coords[ 0 ] );\n\tcoords[ 1 ] = normalize( coords[ 1 ] );\n\tcoords[ 2 ] = normalize( coords[ 2 ] );\n\tcoords[ 3 ] = normalize( coords[ 3 ] );\n\tvec3 vectorFormFactor = vec3( 0.0 );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 0 ], coords[ 1 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 1 ], coords[ 2 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 2 ], coords[ 3 ] );\n\tvectorFormFactor += LTC_EdgeVectorFormFactor( coords[ 3 ], coords[ 0 ] );\n\tfloat result = LTC_ClippedSphereFormFactor( vectorFormFactor );\n\treturn vec3( result );\n}\nvec3 BRDF_Specular_GGX_Environment( const in GeometricContext geometry, const in vec3 specularColor, const in float roughness ) {\n\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\tconst vec4 c0 = vec4( - 1, - 0.0275, - 0.572, 0.022 );\n\tconst vec4 c1 = vec4( 1, 0.0425, 1.04, - 0.04 );\n\tvec4 r = roughness * c0 + c1;\n\tfloat a004 = min( r.x * r.x, exp2( - 9.28 * dotNV ) ) * r.x + r.y;\n\tvec2 AB = vec2( -1.04, 1.04 ) * a004 + r.zw;\n\treturn specularColor * AB.x + AB.y;\n}\nfloat G_BlinnPhong_Implicit( ) {\n\treturn 0.25;\n}\nfloat D_BlinnPhong( const in float shininess, const in float dotNH ) {\n\treturn RECIPROCAL_PI * ( shininess * 0.5 + 1.0 ) * pow( dotNH, shininess );\n}\nvec3 BRDF_Specular_BlinnPhong( const in IncidentLight incidentLight, const in GeometricContext geometry, const in vec3 specularColor, const in float shininess ) {\n\tvec3 halfDir = normalize( incidentLight.direction + geometry.viewDir );\n\tfloat dotNH = saturate( dot( geometry.normal, halfDir ) );\n\tfloat dotLH = saturate( dot( incidentLight.direction, halfDir ) );\n\tvec3 F = F_Schlick( specularColor, dotLH );\n\tfloat G = G_BlinnPhong_Implicit( );\n\tfloat D = D_BlinnPhong( shininess, dotNH );\n\treturn F * ( G * D );\n}\nfloat GGXRoughnessToBlinnExponent( const in float ggxRoughness ) {\n\treturn ( 2.0 / pow2( ggxRoughness + 0.0001 ) - 2.0 );\n}\nfloat BlinnExponentToGGXRoughness( const in float blinnExponent ) {\n\treturn sqrt( 2.0 / ( blinnExponent + 2.0 ) );\n}\n",bumpmap_pars_fragment:"#ifdef USE_BUMPMAP\n\tuniform sampler2D bumpMap;\n\tuniform float bumpScale;\n\tvec2 dHdxy_fwd() {\n\t\tvec2 dSTdx = dFdx( vUv );\n\t\tvec2 dSTdy = dFdy( vUv );\n\t\tfloat Hll = bumpScale * texture2D( bumpMap, vUv ).x;\n\t\tfloat dBx = bumpScale * texture2D( bumpMap, vUv + dSTdx ).x - Hll;\n\t\tfloat dBy = bumpScale * texture2D( bumpMap, vUv + dSTdy ).x - Hll;\n\t\treturn vec2( dBx, dBy );\n\t}\n\tvec3 perturbNormalArb( vec3 surf_pos, vec3 surf_norm, vec2 dHdxy ) {\n\t\tvec3 vSigmaX = vec3( dFdx( surf_pos.x ), dFdx( surf_pos.y ), dFdx( surf_pos.z ) );\n\t\tvec3 vSigmaY = vec3( dFdy( surf_pos.x ), dFdy( surf_pos.y ), dFdy( surf_pos.z ) );\n\t\tvec3 vN = surf_norm;\n\t\tvec3 R1 = cross( vSigmaY, vN );\n\t\tvec3 R2 = cross( vN, vSigmaX );\n\t\tfloat fDet = dot( vSigmaX, R1 );\n\t\tfDet *= ( float( gl_FrontFacing ) * 2.0 - 1.0 );\n\t\tvec3 vGrad = sign( fDet ) * ( dHdxy.x * R1 + dHdxy.y * R2 );\n\t\treturn normalize( abs( fDet ) * surf_norm - vGrad );\n\t}\n#endif\n",clipping_planes_fragment:"#if NUM_CLIPPING_PLANES > 0\n\tvec4 plane;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < UNION_CLIPPING_PLANES; i ++ ) {\n\t\tplane = clippingPlanes[ i ];\n\t\tif ( dot( vViewPosition, plane.xyz ) > plane.w ) discard;\n\t}\n\t#if UNION_CLIPPING_PLANES < NUM_CLIPPING_PLANES\n\t\tbool clipped = true;\n\t\t#pragma unroll_loop\n\t\tfor ( int i = UNION_CLIPPING_PLANES; i < NUM_CLIPPING_PLANES; i ++ ) {\n\t\t\tplane = clippingPlanes[ i ];\n\t\t\tclipped = ( dot( vViewPosition, plane.xyz ) > plane.w ) && clipped;\n\t\t}\n\t\tif ( clipped ) discard;\n\t#endif\n#endif\n",clipping_planes_pars_fragment:"#if NUM_CLIPPING_PLANES > 0\n\t#if ! defined( PHYSICAL ) && ! defined( PHONG )\n\t\tvarying vec3 vViewPosition;\n\t#endif\n\tuniform vec4 clippingPlanes[ NUM_CLIPPING_PLANES ];\n#endif\n",clipping_planes_pars_vertex:"#if NUM_CLIPPING_PLANES > 0 && ! defined( PHYSICAL ) && ! defined( PHONG )\n\tvarying vec3 vViewPosition;\n#endif\n",clipping_planes_vertex:"#if NUM_CLIPPING_PLANES > 0 && ! defined( PHYSICAL ) && ! defined( PHONG )\n\tvViewPosition = - mvPosition.xyz;\n#endif\n",color_fragment:"#ifdef USE_COLOR\n\tdiffuseColor.rgb *= vColor;\n#endif",color_pars_fragment:"#ifdef USE_COLOR\n\tvarying vec3 vColor;\n#endif\n",color_pars_vertex:"#ifdef USE_COLOR\n\tvarying vec3 vColor;\n#endif",color_vertex:"#ifdef USE_COLOR\n\tvColor.xyz = color.xyz;\n#endif",common:"#define PI 3.14159265359\n#define PI2 6.28318530718\n#define PI_HALF 1.5707963267949\n#define RECIPROCAL_PI 0.31830988618\n#define RECIPROCAL_PI2 0.15915494\n#define LOG2 1.442695\n#define EPSILON 1e-6\n#define saturate(a) clamp( a, 0.0, 1.0 )\n#define whiteCompliment(a) ( 1.0 - saturate( a ) )\nfloat pow2( const in float x ) { return x*x; }\nfloat pow3( const in float x ) { return x*x*x; }\nfloat pow4( const in float x ) { float x2 = x*x; return x2*x2; }\nfloat average( const in vec3 color ) { return dot( color, vec3( 0.3333 ) ); }\nhighp float rand( const in vec2 uv ) {\n\tconst highp float a = 12.9898, b = 78.233, c = 43758.5453;\n\thighp float dt = dot( uv.xy, vec2( a,b ) ), sn = mod( dt, PI );\n\treturn fract(sin(sn) * c);\n}\nstruct IncidentLight {\n\tvec3 color;\n\tvec3 direction;\n\tbool visible;\n};\nstruct ReflectedLight {\n\tvec3 directDiffuse;\n\tvec3 directSpecular;\n\tvec3 indirectDiffuse;\n\tvec3 indirectSpecular;\n};\nstruct GeometricContext {\n\tvec3 position;\n\tvec3 normal;\n\tvec3 viewDir;\n};\nvec3 transformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( matrix * vec4( dir, 0.0 ) ).xyz );\n}\nvec3 inverseTransformDirection( in vec3 dir, in mat4 matrix ) {\n\treturn normalize( ( vec4( dir, 0.0 ) * matrix ).xyz );\n}\nvec3 projectOnPlane(in vec3 point, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\tfloat distance = dot( planeNormal, point - pointOnPlane );\n\treturn - distance * planeNormal + point;\n}\nfloat sideOfPlane( in vec3 point, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\treturn sign( dot( point - pointOnPlane, planeNormal ) );\n}\nvec3 linePlaneIntersect( in vec3 pointOnLine, in vec3 lineDirection, in vec3 pointOnPlane, in vec3 planeNormal ) {\n\treturn lineDirection * ( dot( planeNormal, pointOnPlane - pointOnLine ) / dot( planeNormal, lineDirection ) ) + pointOnLine;\n}\nmat3 transposeMat3( const in mat3 m ) {\n\tmat3 tmp;\n\ttmp[ 0 ] = vec3( m[ 0 ].x, m[ 1 ].x, m[ 2 ].x );\n\ttmp[ 1 ] = vec3( m[ 0 ].y, m[ 1 ].y, m[ 2 ].y );\n\ttmp[ 2 ] = vec3( m[ 0 ].z, m[ 1 ].z, m[ 2 ].z );\n\treturn tmp;\n}\nfloat linearToRelativeLuminance( const in vec3 color ) {\n\tvec3 weights = vec3( 0.2126, 0.7152, 0.0722 );\n\treturn dot( weights, color.rgb );\n}\n",cube_uv_reflection_fragment:"#ifdef ENVMAP_TYPE_CUBE_UV\n#define cubeUV_textureSize (1024.0)\nint getFaceFromDirection(vec3 direction) {\n\tvec3 absDirection = abs(direction);\n\tint face = -1;\n\tif( absDirection.x > absDirection.z ) {\n\t\tif(absDirection.x > absDirection.y )\n\t\t\tface = direction.x > 0.0 ? 0 : 3;\n\t\telse\n\t\t\tface = direction.y > 0.0 ? 1 : 4;\n\t}\n\telse {\n\t\tif(absDirection.z > absDirection.y )\n\t\t\tface = direction.z > 0.0 ? 2 : 5;\n\t\telse\n\t\t\tface = direction.y > 0.0 ? 1 : 4;\n\t}\n\treturn face;\n}\n#define cubeUV_maxLods1 (log2(cubeUV_textureSize*0.25) - 1.0)\n#define cubeUV_rangeClamp (exp2((6.0 - 1.0) * 2.0))\nvec2 MipLevelInfo( vec3 vec, float roughnessLevel, float roughness ) {\n\tfloat scale = exp2(cubeUV_maxLods1 - roughnessLevel);\n\tfloat dxRoughness = dFdx(roughness);\n\tfloat dyRoughness = dFdy(roughness);\n\tvec3 dx = dFdx( vec * scale * dxRoughness );\n\tvec3 dy = dFdy( vec * scale * dyRoughness );\n\tfloat d = max( dot( dx, dx ), dot( dy, dy ) );\n\td = clamp(d, 1.0, cubeUV_rangeClamp);\n\tfloat mipLevel = 0.5 * log2(d);\n\treturn vec2(floor(mipLevel), fract(mipLevel));\n}\n#define cubeUV_maxLods2 (log2(cubeUV_textureSize*0.25) - 2.0)\n#define cubeUV_rcpTextureSize (1.0 / cubeUV_textureSize)\nvec2 getCubeUV(vec3 direction, float roughnessLevel, float mipLevel) {\n\tmipLevel = roughnessLevel > cubeUV_maxLods2 - 3.0 ? 0.0 : mipLevel;\n\tfloat a = 16.0 * cubeUV_rcpTextureSize;\n\tvec2 exp2_packed = exp2( vec2( roughnessLevel, mipLevel ) );\n\tvec2 rcp_exp2_packed = vec2( 1.0 ) / exp2_packed;\n\tfloat powScale = exp2_packed.x * exp2_packed.y;\n\tfloat scale = rcp_exp2_packed.x * rcp_exp2_packed.y * 0.25;\n\tfloat mipOffset = 0.75*(1.0 - rcp_exp2_packed.y) * rcp_exp2_packed.x;\n\tbool bRes = mipLevel == 0.0;\n\tscale = bRes && (scale < a) ? a : scale;\n\tvec3 r;\n\tvec2 offset;\n\tint face = getFaceFromDirection(direction);\n\tfloat rcpPowScale = 1.0 / powScale;\n\tif( face == 0) {\n\t\tr = vec3(direction.x, -direction.z, direction.y);\n\t\toffset = vec2(0.0+mipOffset,0.75 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? a : offset.y;\n\t}\n\telse if( face == 1) {\n\t\tr = vec3(direction.y, direction.x, direction.z);\n\t\toffset = vec2(scale+mipOffset, 0.75 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? a : offset.y;\n\t}\n\telse if( face == 2) {\n\t\tr = vec3(direction.z, direction.x, direction.y);\n\t\toffset = vec2(2.0*scale+mipOffset, 0.75 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? a : offset.y;\n\t}\n\telse if( face == 3) {\n\t\tr = vec3(direction.x, direction.z, direction.y);\n\t\toffset = vec2(0.0+mipOffset,0.5 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? 0.0 : offset.y;\n\t}\n\telse if( face == 4) {\n\t\tr = vec3(direction.y, direction.x, -direction.z);\n\t\toffset = vec2(scale+mipOffset, 0.5 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? 0.0 : offset.y;\n\t}\n\telse {\n\t\tr = vec3(direction.z, -direction.x, direction.y);\n\t\toffset = vec2(2.0*scale+mipOffset, 0.5 * rcpPowScale);\n\t\toffset.y = bRes && (offset.y < 2.0*a) ? 0.0 : offset.y;\n\t}\n\tr = normalize(r);\n\tfloat texelOffset = 0.5 * cubeUV_rcpTextureSize;\n\tvec2 s = ( r.yz / abs( r.x ) + vec2( 1.0 ) ) * 0.5;\n\tvec2 base = offset + vec2( texelOffset );\n\treturn base + s * ( scale - 2.0 * texelOffset );\n}\n#define cubeUV_maxLods3 (log2(cubeUV_textureSize*0.25) - 3.0)\nvec4 textureCubeUV(vec3 reflectedDirection, float roughness ) {\n\tfloat roughnessVal = roughness* cubeUV_maxLods3;\n\tfloat r1 = floor(roughnessVal);\n\tfloat r2 = r1 + 1.0;\n\tfloat t = fract(roughnessVal);\n\tvec2 mipInfo = MipLevelInfo(reflectedDirection, r1, roughness);\n\tfloat s = mipInfo.y;\n\tfloat level0 = mipInfo.x;\n\tfloat level1 = level0 + 1.0;\n\tlevel1 = level1 > 5.0 ? 5.0 : level1;\n\tlevel0 += min( floor( s + 0.5 ), 5.0 );\n\tvec2 uv_10 = getCubeUV(reflectedDirection, r1, level0);\n\tvec4 color10 = envMapTexelToLinear(texture2D(envMap, uv_10));\n\tvec2 uv_20 = getCubeUV(reflectedDirection, r2, level0);\n\tvec4 color20 = envMapTexelToLinear(texture2D(envMap, uv_20));\n\tvec4 result = mix(color10, color20, t);\n\treturn vec4(result.rgb, 1.0);\n}\n#endif\n",defaultnormal_vertex:"vec3 transformedNormal = normalMatrix * objectNormal;\n#ifdef FLIP_SIDED\n\ttransformedNormal = - transformedNormal;\n#endif\n",displacementmap_pars_vertex:"#ifdef USE_DISPLACEMENTMAP\n\tuniform sampler2D displacementMap;\n\tuniform float displacementScale;\n\tuniform float displacementBias;\n#endif\n",displacementmap_vertex:"#ifdef USE_DISPLACEMENTMAP\n\ttransformed += normalize( objectNormal ) * ( texture2D( displacementMap, uv ).x * displacementScale + displacementBias );\n#endif\n",emissivemap_fragment:"#ifdef USE_EMISSIVEMAP\n\tvec4 emissiveColor = texture2D( emissiveMap, vUv );\n\temissiveColor.rgb = emissiveMapTexelToLinear( emissiveColor ).rgb;\n\ttotalEmissiveRadiance *= emissiveColor.rgb;\n#endif\n",emissivemap_pars_fragment:"#ifdef USE_EMISSIVEMAP\n\tuniform sampler2D emissiveMap;\n#endif\n",encodings_fragment:" gl_FragColor = linearToOutputTexel( gl_FragColor );\n",encodings_pars_fragment:"\nvec4 LinearToLinear( in vec4 value ) {\n\treturn value;\n}\nvec4 GammaToLinear( in vec4 value, in float gammaFactor ) {\n\treturn vec4( pow( value.xyz, vec3( gammaFactor ) ), value.w );\n}\nvec4 LinearToGamma( in vec4 value, in float gammaFactor ) {\n\treturn vec4( pow( value.xyz, vec3( 1.0 / gammaFactor ) ), value.w );\n}\nvec4 sRGBToLinear( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb * 0.9478672986 + vec3( 0.0521327014 ), vec3( 2.4 ) ), value.rgb * 0.0773993808, vec3( lessThanEqual( value.rgb, vec3( 0.04045 ) ) ) ), value.w );\n}\nvec4 LinearTosRGB( in vec4 value ) {\n\treturn vec4( mix( pow( value.rgb, vec3( 0.41666 ) ) * 1.055 - vec3( 0.055 ), value.rgb * 12.92, vec3( lessThanEqual( value.rgb, vec3( 0.0031308 ) ) ) ), value.w );\n}\nvec4 RGBEToLinear( in vec4 value ) {\n\treturn vec4( value.rgb * exp2( value.a * 255.0 - 128.0 ), 1.0 );\n}\nvec4 LinearToRGBE( in vec4 value ) {\n\tfloat maxComponent = max( max( value.r, value.g ), value.b );\n\tfloat fExp = clamp( ceil( log2( maxComponent ) ), -128.0, 127.0 );\n\treturn vec4( value.rgb / exp2( fExp ), ( fExp + 128.0 ) / 255.0 );\n}\nvec4 RGBMToLinear( in vec4 value, in float maxRange ) {\n\treturn vec4( value.xyz * value.w * maxRange, 1.0 );\n}\nvec4 LinearToRGBM( in vec4 value, in float maxRange ) {\n\tfloat maxRGB = max( value.x, max( value.g, value.b ) );\n\tfloat M = clamp( maxRGB / maxRange, 0.0, 1.0 );\n\tM = ceil( M * 255.0 ) / 255.0;\n\treturn vec4( value.rgb / ( M * maxRange ), M );\n}\nvec4 RGBDToLinear( in vec4 value, in float maxRange ) {\n\treturn vec4( value.rgb * ( ( maxRange / 255.0 ) / value.a ), 1.0 );\n}\nvec4 LinearToRGBD( in vec4 value, in float maxRange ) {\n\tfloat maxRGB = max( value.x, max( value.g, value.b ) );\n\tfloat D = max( maxRange / maxRGB, 1.0 );\n\tD = min( floor( D ) / 255.0, 1.0 );\n\treturn vec4( value.rgb * ( D * ( 255.0 / maxRange ) ), D );\n}\nconst mat3 cLogLuvM = mat3( 0.2209, 0.3390, 0.4184, 0.1138, 0.6780, 0.7319, 0.0102, 0.1130, 0.2969 );\nvec4 LinearToLogLuv( in vec4 value ) {\n\tvec3 Xp_Y_XYZp = value.rgb * cLogLuvM;\n\tXp_Y_XYZp = max(Xp_Y_XYZp, vec3(1e-6, 1e-6, 1e-6));\n\tvec4 vResult;\n\tvResult.xy = Xp_Y_XYZp.xy / Xp_Y_XYZp.z;\n\tfloat Le = 2.0 * log2(Xp_Y_XYZp.y) + 127.0;\n\tvResult.w = fract(Le);\n\tvResult.z = (Le - (floor(vResult.w*255.0))/255.0)/255.0;\n\treturn vResult;\n}\nconst mat3 cLogLuvInverseM = mat3( 6.0014, -2.7008, -1.7996, -1.3320, 3.1029, -5.7721, 0.3008, -1.0882, 5.6268 );\nvec4 LogLuvToLinear( in vec4 value ) {\n\tfloat Le = value.z * 255.0 + value.w;\n\tvec3 Xp_Y_XYZp;\n\tXp_Y_XYZp.y = exp2((Le - 127.0) / 2.0);\n\tXp_Y_XYZp.z = Xp_Y_XYZp.y / value.y;\n\tXp_Y_XYZp.x = value.x * Xp_Y_XYZp.z;\n\tvec3 vRGB = Xp_Y_XYZp.rgb * cLogLuvInverseM;\n\treturn vec4( max(vRGB, 0.0), 1.0 );\n}\n",envmap_fragment:"#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG )\n\t\tvec3 cameraToVertex = normalize( vWorldPosition - cameraPosition );\n\t\tvec3 worldNormal = inverseTransformDirection( normal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( cameraToVertex, worldNormal );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( cameraToVertex, worldNormal, refractionRatio );\n\t\t#endif\n\t#else\n\t\tvec3 reflectVec = vReflect;\n\t#endif\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tvec4 envColor = textureCube( envMap, vec3( flipEnvMap * reflectVec.x, reflectVec.yz ) );\n\t#elif defined( ENVMAP_TYPE_EQUIREC )\n\t\tvec2 sampleUV;\n\t\treflectVec = normalize( reflectVec );\n\t\tsampleUV.y = asin( clamp( reflectVec.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\t\tsampleUV.x = atan( reflectVec.z, reflectVec.x ) * RECIPROCAL_PI2 + 0.5;\n\t\tvec4 envColor = texture2D( envMap, sampleUV );\n\t#elif defined( ENVMAP_TYPE_SPHERE )\n\t\treflectVec = normalize( reflectVec );\n\t\tvec3 reflectView = normalize( ( viewMatrix * vec4( reflectVec, 0.0 ) ).xyz + vec3( 0.0, 0.0, 1.0 ) );\n\t\tvec4 envColor = texture2D( envMap, reflectView.xy * 0.5 + 0.5 );\n\t#else\n\t\tvec4 envColor = vec4( 0.0 );\n\t#endif\n\tenvColor = envMapTexelToLinear( envColor );\n\t#ifdef ENVMAP_BLENDING_MULTIPLY\n\t\toutgoingLight = mix( outgoingLight, outgoingLight * envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_MIX )\n\t\toutgoingLight = mix( outgoingLight, envColor.xyz, specularStrength * reflectivity );\n\t#elif defined( ENVMAP_BLENDING_ADD )\n\t\toutgoingLight += envColor.xyz * specularStrength * reflectivity;\n\t#endif\n#endif\n",envmap_pars_fragment:"#if defined( USE_ENVMAP ) || defined( PHYSICAL )\n\tuniform float reflectivity;\n\tuniform float envMapIntensity;\n#endif\n#ifdef USE_ENVMAP\n\t#if ! defined( PHYSICAL ) && ( defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) )\n\t\tvarying vec3 vWorldPosition;\n\t#endif\n\t#ifdef ENVMAP_TYPE_CUBE\n\t\tuniform samplerCube envMap;\n\t#else\n\t\tuniform sampler2D envMap;\n\t#endif\n\tuniform float flipEnvMap;\n\tuniform int maxMipLevel;\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG ) || defined( PHYSICAL )\n\t\tuniform float refractionRatio;\n\t#else\n\t\tvarying vec3 vReflect;\n\t#endif\n#endif\n",envmap_pars_vertex:"#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG )\n\t\tvarying vec3 vWorldPosition;\n\t#else\n\t\tvarying vec3 vReflect;\n\t\tuniform float refractionRatio;\n\t#endif\n#endif\n",envmap_vertex:"#ifdef USE_ENVMAP\n\t#if defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( PHONG )\n\t\tvWorldPosition = worldPosition.xyz;\n\t#else\n\t\tvec3 cameraToVertex = normalize( worldPosition.xyz - cameraPosition );\n\t\tvec3 worldNormal = inverseTransformDirection( transformedNormal, viewMatrix );\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvReflect = reflect( cameraToVertex, worldNormal );\n\t\t#else\n\t\t\tvReflect = refract( cameraToVertex, worldNormal, refractionRatio );\n\t\t#endif\n\t#endif\n#endif\n",fog_vertex:"\n#ifdef USE_FOG\nfogDepth = -mvPosition.z;\n#endif",fog_pars_vertex:"#ifdef USE_FOG\n varying float fogDepth;\n#endif\n",fog_fragment:"#ifdef USE_FOG\n\t#ifdef FOG_EXP2\n\t\tfloat fogFactor = whiteCompliment( exp2( - fogDensity * fogDensity * fogDepth * fogDepth * LOG2 ) );\n\t#else\n\t\tfloat fogFactor = smoothstep( fogNear, fogFar, fogDepth );\n\t#endif\n\tgl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );\n#endif\n",fog_pars_fragment:"#ifdef USE_FOG\n\tuniform vec3 fogColor;\n\tvarying float fogDepth;\n\t#ifdef FOG_EXP2\n\t\tuniform float fogDensity;\n\t#else\n\t\tuniform float fogNear;\n\t\tuniform float fogFar;\n\t#endif\n#endif\n",gradientmap_pars_fragment:"#ifdef TOON\n\tuniform sampler2D gradientMap;\n\tvec3 getGradientIrradiance( vec3 normal, vec3 lightDirection ) {\n\t\tfloat dotNL = dot( normal, lightDirection );\n\t\tvec2 coord = vec2( dotNL * 0.5 + 0.5, 0.0 );\n\t\t#ifdef USE_GRADIENTMAP\n\t\t\treturn texture2D( gradientMap, coord ).rgb;\n\t\t#else\n\t\t\treturn ( coord.x < 0.7 ) ? vec3( 0.7 ) : vec3( 1.0 );\n\t\t#endif\n\t}\n#endif\n",lightmap_fragment:"#ifdef USE_LIGHTMAP\n\treflectedLight.indirectDiffuse += PI * texture2D( lightMap, vUv2 ).xyz * lightMapIntensity;\n#endif\n",lightmap_pars_fragment:"#ifdef USE_LIGHTMAP\n\tuniform sampler2D lightMap;\n\tuniform float lightMapIntensity;\n#endif",lights_lambert_vertex:"vec3 diffuse = vec3( 1.0 );\nGeometricContext geometry;\ngeometry.position = mvPosition.xyz;\ngeometry.normal = normalize( transformedNormal );\ngeometry.viewDir = normalize( -mvPosition.xyz );\nGeometricContext backGeometry;\nbackGeometry.position = geometry.position;\nbackGeometry.normal = -geometry.normal;\nbackGeometry.viewDir = geometry.viewDir;\nvLightFront = vec3( 0.0 );\n#ifdef DOUBLE_SIDED\n\tvLightBack = vec3( 0.0 );\n#endif\nIncidentLight directLight;\nfloat dotNL;\nvec3 directLightColor_Diffuse;\n#if NUM_POINT_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tgetPointDirectLightIrradiance( pointLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tgetSpotDirectLightIrradiance( spotLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n#endif\n#if NUM_DIR_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tgetDirectionalDirectLightIrradiance( directionalLights[ i ], geometry, directLight );\n\t\tdotNL = dot( geometry.normal, directLight.direction );\n\t\tdirectLightColor_Diffuse = PI * directLight.color;\n\t\tvLightFront += saturate( dotNL ) * directLightColor_Diffuse;\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += saturate( -dotNL ) * directLightColor_Diffuse;\n\t\t#endif\n\t}\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\tvLightFront += getHemisphereLightIrradiance( hemisphereLights[ i ], geometry );\n\t\t#ifdef DOUBLE_SIDED\n\t\t\tvLightBack += getHemisphereLightIrradiance( hemisphereLights[ i ], backGeometry );\n\t\t#endif\n\t}\n#endif\n",lights_pars_begin:"uniform vec3 ambientLightColor;\nvec3 getAmbientLightIrradiance( const in vec3 ambientLightColor ) {\n\tvec3 irradiance = ambientLightColor;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treturn irradiance;\n}\n#if NUM_DIR_LIGHTS > 0\n\tstruct DirectionalLight {\n\t\tvec3 direction;\n\t\tvec3 color;\n\t\tint shadow;\n\t\tfloat shadowBias;\n\t\tfloat shadowRadius;\n\t\tvec2 shadowMapSize;\n\t};\n\tuniform DirectionalLight directionalLights[ NUM_DIR_LIGHTS ];\n\tvoid getDirectionalDirectLightIrradiance( const in DirectionalLight directionalLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tdirectLight.color = directionalLight.color;\n\t\tdirectLight.direction = directionalLight.direction;\n\t\tdirectLight.visible = true;\n\t}\n#endif\n#if NUM_POINT_LIGHTS > 0\n\tstruct PointLight {\n\t\tvec3 position;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t\tint shadow;\n\t\tfloat shadowBias;\n\t\tfloat shadowRadius;\n\t\tvec2 shadowMapSize;\n\t\tfloat shadowCameraNear;\n\t\tfloat shadowCameraFar;\n\t};\n\tuniform PointLight pointLights[ NUM_POINT_LIGHTS ];\n\tvoid getPointDirectLightIrradiance( const in PointLight pointLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tvec3 lVector = pointLight.position - geometry.position;\n\t\tdirectLight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tdirectLight.color = pointLight.color;\n\t\tdirectLight.color *= punctualLightIntensityToIrradianceFactor( lightDistance, pointLight.distance, pointLight.decay );\n\t\tdirectLight.visible = ( directLight.color != vec3( 0.0 ) );\n\t}\n#endif\n#if NUM_SPOT_LIGHTS > 0\n\tstruct SpotLight {\n\t\tvec3 position;\n\t\tvec3 direction;\n\t\tvec3 color;\n\t\tfloat distance;\n\t\tfloat decay;\n\t\tfloat coneCos;\n\t\tfloat penumbraCos;\n\t\tint shadow;\n\t\tfloat shadowBias;\n\t\tfloat shadowRadius;\n\t\tvec2 shadowMapSize;\n\t};\n\tuniform SpotLight spotLights[ NUM_SPOT_LIGHTS ];\n\tvoid getSpotDirectLightIrradiance( const in SpotLight spotLight, const in GeometricContext geometry, out IncidentLight directLight ) {\n\t\tvec3 lVector = spotLight.position - geometry.position;\n\t\tdirectLight.direction = normalize( lVector );\n\t\tfloat lightDistance = length( lVector );\n\t\tfloat angleCos = dot( directLight.direction, spotLight.direction );\n\t\tif ( angleCos > spotLight.coneCos ) {\n\t\t\tfloat spotEffect = smoothstep( spotLight.coneCos, spotLight.penumbraCos, angleCos );\n\t\t\tdirectLight.color = spotLight.color;\n\t\t\tdirectLight.color *= spotEffect * punctualLightIntensityToIrradianceFactor( lightDistance, spotLight.distance, spotLight.decay );\n\t\t\tdirectLight.visible = true;\n\t\t} else {\n\t\t\tdirectLight.color = vec3( 0.0 );\n\t\t\tdirectLight.visible = false;\n\t\t}\n\t}\n#endif\n#if NUM_RECT_AREA_LIGHTS > 0\n\tstruct RectAreaLight {\n\t\tvec3 color;\n\t\tvec3 position;\n\t\tvec3 halfWidth;\n\t\tvec3 halfHeight;\n\t};\n\tuniform sampler2D ltc_1;\tuniform sampler2D ltc_2;\n\tuniform RectAreaLight rectAreaLights[ NUM_RECT_AREA_LIGHTS ];\n#endif\n#if NUM_HEMI_LIGHTS > 0\n\tstruct HemisphereLight {\n\t\tvec3 direction;\n\t\tvec3 skyColor;\n\t\tvec3 groundColor;\n\t};\n\tuniform HemisphereLight hemisphereLights[ NUM_HEMI_LIGHTS ];\n\tvec3 getHemisphereLightIrradiance( const in HemisphereLight hemiLight, const in GeometricContext geometry ) {\n\t\tfloat dotNL = dot( geometry.normal, hemiLight.direction );\n\t\tfloat hemiDiffuseWeight = 0.5 * dotNL + 0.5;\n\t\tvec3 irradiance = mix( hemiLight.groundColor, hemiLight.skyColor, hemiDiffuseWeight );\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tirradiance *= PI;\n\t\t#endif\n\t\treturn irradiance;\n\t}\n#endif\n",lights_pars_maps:"#if defined( USE_ENVMAP ) && defined( PHYSICAL )\n\tvec3 getLightProbeIndirectIrradiance( const in GeometricContext geometry, const in int maxMIPLevel ) {\n\t\tvec3 worldNormal = inverseTransformDirection( geometry.normal, viewMatrix );\n\t\t#ifdef ENVMAP_TYPE_CUBE\n\t\t\tvec3 queryVec = vec3( flipEnvMap * worldNormal.x, worldNormal.yz );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = textureCubeLodEXT( envMap, queryVec, float( maxMIPLevel ) );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = textureCube( envMap, queryVec, float( maxMIPLevel ) );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\t\tvec3 queryVec = vec3( flipEnvMap * worldNormal.x, worldNormal.yz );\n\t\t\tvec4 envMapColor = textureCubeUV( queryVec, 1.0 );\n\t\t#else\n\t\t\tvec4 envMapColor = vec4( 0.0 );\n\t\t#endif\n\t\treturn PI * envMapColor.rgb * envMapIntensity;\n\t}\n\tfloat getSpecularMIPLevel( const in float blinnShininessExponent, const in int maxMIPLevel ) {\n\t\tfloat maxMIPLevelScalar = float( maxMIPLevel );\n\t\tfloat desiredMIPLevel = maxMIPLevelScalar + 0.79248 - 0.5 * log2( pow2( blinnShininessExponent ) + 1.0 );\n\t\treturn clamp( desiredMIPLevel, 0.0, maxMIPLevelScalar );\n\t}\n\tvec3 getLightProbeIndirectRadiance( const in GeometricContext geometry, const in float blinnShininessExponent, const in int maxMIPLevel ) {\n\t\t#ifdef ENVMAP_MODE_REFLECTION\n\t\t\tvec3 reflectVec = reflect( -geometry.viewDir, geometry.normal );\n\t\t#else\n\t\t\tvec3 reflectVec = refract( -geometry.viewDir, geometry.normal, refractionRatio );\n\t\t#endif\n\t\treflectVec = inverseTransformDirection( reflectVec, viewMatrix );\n\t\tfloat specularMIPLevel = getSpecularMIPLevel( blinnShininessExponent, maxMIPLevel );\n\t\t#ifdef ENVMAP_TYPE_CUBE\n\t\t\tvec3 queryReflectVec = vec3( flipEnvMap * reflectVec.x, reflectVec.yz );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = textureCubeLodEXT( envMap, queryReflectVec, specularMIPLevel );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = textureCube( envMap, queryReflectVec, specularMIPLevel );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_CUBE_UV )\n\t\t\tvec3 queryReflectVec = vec3( flipEnvMap * reflectVec.x, reflectVec.yz );\n\t\t\tvec4 envMapColor = textureCubeUV(queryReflectVec, BlinnExponentToGGXRoughness(blinnShininessExponent));\n\t\t#elif defined( ENVMAP_TYPE_EQUIREC )\n\t\t\tvec2 sampleUV;\n\t\t\tsampleUV.y = asin( clamp( reflectVec.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\t\t\tsampleUV.x = atan( reflectVec.z, reflectVec.x ) * RECIPROCAL_PI2 + 0.5;\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = texture2DLodEXT( envMap, sampleUV, specularMIPLevel );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = texture2D( envMap, sampleUV, specularMIPLevel );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#elif defined( ENVMAP_TYPE_SPHERE )\n\t\t\tvec3 reflectView = normalize( ( viewMatrix * vec4( reflectVec, 0.0 ) ).xyz + vec3( 0.0,0.0,1.0 ) );\n\t\t\t#ifdef TEXTURE_LOD_EXT\n\t\t\t\tvec4 envMapColor = texture2DLodEXT( envMap, reflectView.xy * 0.5 + 0.5, specularMIPLevel );\n\t\t\t#else\n\t\t\t\tvec4 envMapColor = texture2D( envMap, reflectView.xy * 0.5 + 0.5, specularMIPLevel );\n\t\t\t#endif\n\t\t\tenvMapColor.rgb = envMapTexelToLinear( envMapColor ).rgb;\n\t\t#endif\n\t\treturn envMapColor.rgb * envMapIntensity;\n\t}\n#endif\n",lights_phong_fragment:"BlinnPhongMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb;\nmaterial.specularColor = specular;\nmaterial.specularShininess = shininess;\nmaterial.specularStrength = specularStrength;\n",lights_phong_pars_fragment:"varying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\nstruct BlinnPhongMaterial {\n\tvec3\tdiffuseColor;\n\tvec3\tspecularColor;\n\tfloat\tspecularShininess;\n\tfloat\tspecularStrength;\n};\nvoid RE_Direct_BlinnPhong( const in IncidentLight directLight, const in GeometricContext geometry, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\t#ifdef TOON\n\t\tvec3 irradiance = getGradientIrradiance( geometry.normal, directLight.direction ) * directLight.color;\n\t#else\n\t\tfloat dotNL = saturate( dot( geometry.normal, directLight.direction ) );\n\t\tvec3 irradiance = dotNL * directLight.color;\n\t#endif\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\treflectedLight.directDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n\treflectedLight.directSpecular += irradiance * BRDF_Specular_BlinnPhong( directLight, geometry, material.specularColor, material.specularShininess ) * material.specularStrength;\n}\nvoid RE_IndirectDiffuse_BlinnPhong( const in vec3 irradiance, const in GeometricContext geometry, const in BlinnPhongMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\n#define RE_Direct\t\t\t\tRE_Direct_BlinnPhong\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_BlinnPhong\n#define Material_LightProbeLOD( material )\t(0)\n",lights_physical_fragment:"PhysicalMaterial material;\nmaterial.diffuseColor = diffuseColor.rgb * ( 1.0 - metalnessFactor );\nmaterial.specularRoughness = clamp( roughnessFactor, 0.04, 1.0 );\n#ifdef STANDARD\n\tmaterial.specularColor = mix( vec3( DEFAULT_SPECULAR_COEFFICIENT ), diffuseColor.rgb, metalnessFactor );\n#else\n\tmaterial.specularColor = mix( vec3( MAXIMUM_SPECULAR_COEFFICIENT * pow2( reflectivity ) ), diffuseColor.rgb, metalnessFactor );\n\tmaterial.clearCoat = saturate( clearCoat );\tmaterial.clearCoatRoughness = clamp( clearCoatRoughness, 0.04, 1.0 );\n#endif\n",lights_physical_pars_fragment:"struct PhysicalMaterial {\n\tvec3\tdiffuseColor;\n\tfloat\tspecularRoughness;\n\tvec3\tspecularColor;\n\t#ifndef STANDARD\n\t\tfloat clearCoat;\n\t\tfloat clearCoatRoughness;\n\t#endif\n};\n#define MAXIMUM_SPECULAR_COEFFICIENT 0.16\n#define DEFAULT_SPECULAR_COEFFICIENT 0.04\nfloat clearCoatDHRApprox( const in float roughness, const in float dotNL ) {\n\treturn DEFAULT_SPECULAR_COEFFICIENT + ( 1.0 - DEFAULT_SPECULAR_COEFFICIENT ) * ( pow( 1.0 - dotNL, 5.0 ) * pow( 1.0 - roughness, 2.0 ) );\n}\n#if NUM_RECT_AREA_LIGHTS > 0\n\tvoid RE_Direct_RectArea_Physical( const in RectAreaLight rectAreaLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\t\tvec3 normal = geometry.normal;\n\t\tvec3 viewDir = geometry.viewDir;\n\t\tvec3 position = geometry.position;\n\t\tvec3 lightPos = rectAreaLight.position;\n\t\tvec3 halfWidth = rectAreaLight.halfWidth;\n\t\tvec3 halfHeight = rectAreaLight.halfHeight;\n\t\tvec3 lightColor = rectAreaLight.color;\n\t\tfloat roughness = material.specularRoughness;\n\t\tvec3 rectCoords[ 4 ];\n\t\trectCoords[ 0 ] = lightPos - halfWidth - halfHeight;\t\trectCoords[ 1 ] = lightPos + halfWidth - halfHeight;\n\t\trectCoords[ 2 ] = lightPos + halfWidth + halfHeight;\n\t\trectCoords[ 3 ] = lightPos - halfWidth + halfHeight;\n\t\tvec2 uv = LTC_Uv( normal, viewDir, roughness );\n\t\tvec4 t1 = texture2D( ltc_1, uv );\n\t\tvec4 t2 = texture2D( ltc_2, uv );\n\t\tmat3 mInv = mat3(\n\t\t\tvec3( t1.x, 0, t1.y ),\n\t\t\tvec3( 0, 1, 0 ),\n\t\t\tvec3( t1.z, 0, t1.w )\n\t\t);\n\t\tvec3 fresnel = ( material.specularColor * t2.x + ( vec3( 1.0 ) - material.specularColor ) * t2.y );\n\t\treflectedLight.directSpecular += lightColor * fresnel * LTC_Evaluate( normal, viewDir, position, mInv, rectCoords );\n\t\treflectedLight.directDiffuse += lightColor * material.diffuseColor * LTC_Evaluate( normal, viewDir, position, mat3( 1.0 ), rectCoords );\n\t}\n#endif\nvoid RE_Direct_Physical( const in IncidentLight directLight, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\tfloat dotNL = saturate( dot( geometry.normal, directLight.direction ) );\n\tvec3 irradiance = dotNL * directLight.color;\n\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\tirradiance *= PI;\n\t#endif\n\t#ifndef STANDARD\n\t\tfloat clearCoatDHR = material.clearCoat * clearCoatDHRApprox( material.clearCoatRoughness, dotNL );\n\t#else\n\t\tfloat clearCoatDHR = 0.0;\n\t#endif\n\treflectedLight.directSpecular += ( 1.0 - clearCoatDHR ) * irradiance * BRDF_Specular_GGX( directLight, geometry, material.specularColor, material.specularRoughness );\n\treflectedLight.directDiffuse += ( 1.0 - clearCoatDHR ) * irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n\t#ifndef STANDARD\n\t\treflectedLight.directSpecular += irradiance * material.clearCoat * BRDF_Specular_GGX( directLight, geometry, vec3( DEFAULT_SPECULAR_COEFFICIENT ), material.clearCoatRoughness );\n\t#endif\n}\nvoid RE_IndirectDiffuse_Physical( const in vec3 irradiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\treflectedLight.indirectDiffuse += irradiance * BRDF_Diffuse_Lambert( material.diffuseColor );\n}\nvoid RE_IndirectSpecular_Physical( const in vec3 radiance, const in vec3 clearCoatRadiance, const in GeometricContext geometry, const in PhysicalMaterial material, inout ReflectedLight reflectedLight ) {\n\t#ifndef STANDARD\n\t\tfloat dotNV = saturate( dot( geometry.normal, geometry.viewDir ) );\n\t\tfloat dotNL = dotNV;\n\t\tfloat clearCoatDHR = material.clearCoat * clearCoatDHRApprox( material.clearCoatRoughness, dotNL );\n\t#else\n\t\tfloat clearCoatDHR = 0.0;\n\t#endif\n\treflectedLight.indirectSpecular += ( 1.0 - clearCoatDHR ) * radiance * BRDF_Specular_GGX_Environment( geometry, material.specularColor, material.specularRoughness );\n\t#ifndef STANDARD\n\t\treflectedLight.indirectSpecular += clearCoatRadiance * material.clearCoat * BRDF_Specular_GGX_Environment( geometry, vec3( DEFAULT_SPECULAR_COEFFICIENT ), material.clearCoatRoughness );\n\t#endif\n}\n#define RE_Direct\t\t\t\tRE_Direct_Physical\n#define RE_Direct_RectArea\t\tRE_Direct_RectArea_Physical\n#define RE_IndirectDiffuse\t\tRE_IndirectDiffuse_Physical\n#define RE_IndirectSpecular\t\tRE_IndirectSpecular_Physical\n#define Material_BlinnShininessExponent( material ) GGXRoughnessToBlinnExponent( material.specularRoughness )\n#define Material_ClearCoat_BlinnShininessExponent( material ) GGXRoughnessToBlinnExponent( material.clearCoatRoughness )\nfloat computeSpecularOcclusion( const in float dotNV, const in float ambientOcclusion, const in float roughness ) {\n\treturn saturate( pow( dotNV + ambientOcclusion, exp2( - 16.0 * roughness - 1.0 ) ) - 1.0 + ambientOcclusion );\n}\n",lights_fragment_begin:"\nGeometricContext geometry;\ngeometry.position = - vViewPosition;\ngeometry.normal = normal;\ngeometry.viewDir = normalize( vViewPosition );\nIncidentLight directLight;\n#if ( NUM_POINT_LIGHTS > 0 ) && defined( RE_Direct )\n\tPointLight pointLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tpointLight = pointLights[ i ];\n\t\tgetPointDirectLightIrradiance( pointLight, geometry, directLight );\n\t\t#ifdef USE_SHADOWMAP\n\t\tdirectLight.color *= all( bvec2( pointLight.shadow, directLight.visible ) ) ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n#endif\n#if ( NUM_SPOT_LIGHTS > 0 ) && defined( RE_Direct )\n\tSpotLight spotLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tspotLight = spotLights[ i ];\n\t\tgetSpotDirectLightIrradiance( spotLight, geometry, directLight );\n\t\t#ifdef USE_SHADOWMAP\n\t\tdirectLight.color *= all( bvec2( spotLight.shadow, directLight.visible ) ) ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowBias, spotLight.shadowRadius, vSpotShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n#endif\n#if ( NUM_DIR_LIGHTS > 0 ) && defined( RE_Direct )\n\tDirectionalLight directionalLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tdirectionalLight = directionalLights[ i ];\n\t\tgetDirectionalDirectLightIrradiance( directionalLight, geometry, directLight );\n\t\t#ifdef USE_SHADOWMAP\n\t\tdirectLight.color *= all( bvec2( directionalLight.shadow, directLight.visible ) ) ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t\t#endif\n\t\tRE_Direct( directLight, geometry, material, reflectedLight );\n\t}\n#endif\n#if ( NUM_RECT_AREA_LIGHTS > 0 ) && defined( RE_Direct_RectArea )\n\tRectAreaLight rectAreaLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_RECT_AREA_LIGHTS; i ++ ) {\n\t\trectAreaLight = rectAreaLights[ i ];\n\t\tRE_Direct_RectArea( rectAreaLight, geometry, material, reflectedLight );\n\t}\n#endif\n#if defined( RE_IndirectDiffuse )\n\tvec3 irradiance = getAmbientLightIrradiance( ambientLightColor );\n\t#if ( NUM_HEMI_LIGHTS > 0 )\n\t\t#pragma unroll_loop\n\t\tfor ( int i = 0; i < NUM_HEMI_LIGHTS; i ++ ) {\n\t\t\tirradiance += getHemisphereLightIrradiance( hemisphereLights[ i ], geometry );\n\t\t}\n\t#endif\n#endif\n#if defined( RE_IndirectSpecular )\n\tvec3 radiance = vec3( 0.0 );\n\tvec3 clearCoatRadiance = vec3( 0.0 );\n#endif\n",lights_fragment_maps:"#if defined( RE_IndirectDiffuse )\n\t#ifdef USE_LIGHTMAP\n\t\tvec3 lightMapIrradiance = texture2D( lightMap, vUv2 ).xyz * lightMapIntensity;\n\t\t#ifndef PHYSICALLY_CORRECT_LIGHTS\n\t\t\tlightMapIrradiance *= PI;\n\t\t#endif\n\t\tirradiance += lightMapIrradiance;\n\t#endif\n\t#if defined( USE_ENVMAP ) && defined( PHYSICAL ) && defined( ENVMAP_TYPE_CUBE_UV )\n\t\tirradiance += getLightProbeIndirectIrradiance( geometry, maxMipLevel );\n\t#endif\n#endif\n#if defined( USE_ENVMAP ) && defined( RE_IndirectSpecular )\n\tradiance += getLightProbeIndirectRadiance( geometry, Material_BlinnShininessExponent( material ), maxMipLevel );\n\t#ifndef STANDARD\n\t\tclearCoatRadiance += getLightProbeIndirectRadiance( geometry, Material_ClearCoat_BlinnShininessExponent( material ), maxMipLevel );\n\t#endif\n#endif\n",lights_fragment_end:"#if defined( RE_IndirectDiffuse )\n\tRE_IndirectDiffuse( irradiance, geometry, material, reflectedLight );\n#endif\n#if defined( RE_IndirectSpecular )\n\tRE_IndirectSpecular( radiance, clearCoatRadiance, geometry, material, reflectedLight );\n#endif\n",logdepthbuf_fragment:"#if defined( USE_LOGDEPTHBUF ) && defined( USE_LOGDEPTHBUF_EXT )\n\tgl_FragDepthEXT = log2( vFragDepth ) * logDepthBufFC * 0.5;\n#endif",logdepthbuf_pars_fragment:"#ifdef USE_LOGDEPTHBUF\n\tuniform float logDepthBufFC;\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvarying float vFragDepth;\n\t#endif\n#endif\n",logdepthbuf_pars_vertex:"#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvarying float vFragDepth;\n\t#endif\n\tuniform float logDepthBufFC;\n#endif",logdepthbuf_vertex:"#ifdef USE_LOGDEPTHBUF\n\t#ifdef USE_LOGDEPTHBUF_EXT\n\t\tvFragDepth = 1.0 + gl_Position.w;\n\t#else\n\t\tgl_Position.z = log2( max( EPSILON, gl_Position.w + 1.0 ) ) * logDepthBufFC - 1.0;\n\t\tgl_Position.z *= gl_Position.w;\n\t#endif\n#endif\n",map_fragment:"#ifdef USE_MAP\n\tvec4 texelColor = texture2D( map, vUv );\n\ttexelColor = mapTexelToLinear( texelColor );\n\tdiffuseColor *= texelColor;\n#endif\n",map_pars_fragment:"#ifdef USE_MAP\n\tuniform sampler2D map;\n#endif\n",map_particle_fragment:"#ifdef USE_MAP\n\tvec2 uv = ( uvTransform * vec3( gl_PointCoord.x, 1.0 - gl_PointCoord.y, 1 ) ).xy;\n\tvec4 mapTexel = texture2D( map, uv );\n\tdiffuseColor *= mapTexelToLinear( mapTexel );\n#endif\n",map_particle_pars_fragment:"#ifdef USE_MAP\n\tuniform mat3 uvTransform;\n\tuniform sampler2D map;\n#endif\n",metalnessmap_fragment:"float metalnessFactor = metalness;\n#ifdef USE_METALNESSMAP\n\tvec4 texelMetalness = texture2D( metalnessMap, vUv );\n\tmetalnessFactor *= texelMetalness.b;\n#endif\n",metalnessmap_pars_fragment:"#ifdef USE_METALNESSMAP\n\tuniform sampler2D metalnessMap;\n#endif",morphnormal_vertex:"#ifdef USE_MORPHNORMALS\n\tobjectNormal += ( morphNormal0 - normal ) * morphTargetInfluences[ 0 ];\n\tobjectNormal += ( morphNormal1 - normal ) * morphTargetInfluences[ 1 ];\n\tobjectNormal += ( morphNormal2 - normal ) * morphTargetInfluences[ 2 ];\n\tobjectNormal += ( morphNormal3 - normal ) * morphTargetInfluences[ 3 ];\n#endif\n",morphtarget_pars_vertex:"#ifdef USE_MORPHTARGETS\n\t#ifndef USE_MORPHNORMALS\n\tuniform float morphTargetInfluences[ 8 ];\n\t#else\n\tuniform float morphTargetInfluences[ 4 ];\n\t#endif\n#endif",morphtarget_vertex:"#ifdef USE_MORPHTARGETS\n\ttransformed += ( morphTarget0 - position ) * morphTargetInfluences[ 0 ];\n\ttransformed += ( morphTarget1 - position ) * morphTargetInfluences[ 1 ];\n\ttransformed += ( morphTarget2 - position ) * morphTargetInfluences[ 2 ];\n\ttransformed += ( morphTarget3 - position ) * morphTargetInfluences[ 3 ];\n\t#ifndef USE_MORPHNORMALS\n\ttransformed += ( morphTarget4 - position ) * morphTargetInfluences[ 4 ];\n\ttransformed += ( morphTarget5 - position ) * morphTargetInfluences[ 5 ];\n\ttransformed += ( morphTarget6 - position ) * morphTargetInfluences[ 6 ];\n\ttransformed += ( morphTarget7 - position ) * morphTargetInfluences[ 7 ];\n\t#endif\n#endif\n",normal_fragment_begin:"#ifdef FLAT_SHADED\n\tvec3 fdx = vec3( dFdx( vViewPosition.x ), dFdx( vViewPosition.y ), dFdx( vViewPosition.z ) );\n\tvec3 fdy = vec3( dFdy( vViewPosition.x ), dFdy( vViewPosition.y ), dFdy( vViewPosition.z ) );\n\tvec3 normal = normalize( cross( fdx, fdy ) );\n#else\n\tvec3 normal = normalize( vNormal );\n\t#ifdef DOUBLE_SIDED\n\t\tnormal = normal * ( float( gl_FrontFacing ) * 2.0 - 1.0 );\n\t#endif\n#endif\n",normal_fragment_maps:"#ifdef USE_NORMALMAP\n\tnormal = perturbNormal2Arb( -vViewPosition, normal );\n#elif defined( USE_BUMPMAP )\n\tnormal = perturbNormalArb( -vViewPosition, normal, dHdxy_fwd() );\n#endif\n",normalmap_pars_fragment:"#ifdef USE_NORMALMAP\n\tuniform sampler2D normalMap;\n\tuniform vec2 normalScale;\n\tvec3 perturbNormal2Arb( vec3 eye_pos, vec3 surf_norm ) {\n\t\tvec3 q0 = vec3( dFdx( eye_pos.x ), dFdx( eye_pos.y ), dFdx( eye_pos.z ) );\n\t\tvec3 q1 = vec3( dFdy( eye_pos.x ), dFdy( eye_pos.y ), dFdy( eye_pos.z ) );\n\t\tvec2 st0 = dFdx( vUv.st );\n\t\tvec2 st1 = dFdy( vUv.st );\n\t\tfloat scale = sign( st1.t * st0.s - st0.t * st1.s );\n\t\tvec3 S = normalize( ( q0 * st1.t - q1 * st0.t ) * scale );\n\t\tvec3 T = normalize( ( - q0 * st1.s + q1 * st0.s ) * scale );\n\t\tvec3 N = normalize( surf_norm );\n\t\tmat3 tsn = mat3( S, T, N );\n\t\tvec3 mapN = texture2D( normalMap, vUv ).xyz * 2.0 - 1.0;\n\t\tmapN.xy *= normalScale;\n\t\tmapN.xy *= ( float( gl_FrontFacing ) * 2.0 - 1.0 );\n\t\treturn normalize( tsn * mapN );\n\t}\n#endif\n",packing:"vec3 packNormalToRGB( const in vec3 normal ) {\n\treturn normalize( normal ) * 0.5 + 0.5;\n}\nvec3 unpackRGBToNormal( const in vec3 rgb ) {\n\treturn 2.0 * rgb.xyz - 1.0;\n}\nconst float PackUpscale = 256. / 255.;const float UnpackDownscale = 255. / 256.;\nconst vec3 PackFactors = vec3( 256. * 256. * 256., 256. * 256., 256. );\nconst vec4 UnpackFactors = UnpackDownscale / vec4( PackFactors, 1. );\nconst float ShiftRight8 = 1. / 256.;\nvec4 packDepthToRGBA( const in float v ) {\n\tvec4 r = vec4( fract( v * PackFactors ), v );\n\tr.yzw -= r.xyz * ShiftRight8;\treturn r * PackUpscale;\n}\nfloat unpackRGBAToDepth( const in vec4 v ) {\n\treturn dot( v, UnpackFactors );\n}\nfloat viewZToOrthographicDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn ( viewZ + near ) / ( near - far );\n}\nfloat orthographicDepthToViewZ( const in float linearClipZ, const in float near, const in float far ) {\n\treturn linearClipZ * ( near - far ) - near;\n}\nfloat viewZToPerspectiveDepth( const in float viewZ, const in float near, const in float far ) {\n\treturn (( near + viewZ ) * far ) / (( far - near ) * viewZ );\n}\nfloat perspectiveDepthToViewZ( const in float invClipZ, const in float near, const in float far ) {\n\treturn ( near * far ) / ( ( far - near ) * invClipZ - far );\n}\n",premultiplied_alpha_fragment:"#ifdef PREMULTIPLIED_ALPHA\n\tgl_FragColor.rgb *= gl_FragColor.a;\n#endif\n",project_vertex:"vec4 mvPosition = modelViewMatrix * vec4( transformed, 1.0 );\ngl_Position = projectionMatrix * mvPosition;\n",dithering_fragment:"#if defined( DITHERING )\n gl_FragColor.rgb = dithering( gl_FragColor.rgb );\n#endif\n",dithering_pars_fragment:"#if defined( DITHERING )\n\tvec3 dithering( vec3 color ) {\n\t\tfloat grid_position = rand( gl_FragCoord.xy );\n\t\tvec3 dither_shift_RGB = vec3( 0.25 / 255.0, -0.25 / 255.0, 0.25 / 255.0 );\n\t\tdither_shift_RGB = mix( 2.0 * dither_shift_RGB, -2.0 * dither_shift_RGB, grid_position );\n\t\treturn color + dither_shift_RGB;\n\t}\n#endif\n",roughnessmap_fragment:"float roughnessFactor = roughness;\n#ifdef USE_ROUGHNESSMAP\n\tvec4 texelRoughness = texture2D( roughnessMap, vUv );\n\troughnessFactor *= texelRoughness.g;\n#endif\n",roughnessmap_pars_fragment:"#ifdef USE_ROUGHNESSMAP\n\tuniform sampler2D roughnessMap;\n#endif",shadowmap_pars_fragment:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHTS > 0\n\t\tuniform sampler2D directionalShadowMap[ NUM_DIR_LIGHTS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHTS ];\n\t#endif\n\t#if NUM_SPOT_LIGHTS > 0\n\t\tuniform sampler2D spotShadowMap[ NUM_SPOT_LIGHTS ];\n\t\tvarying vec4 vSpotShadowCoord[ NUM_SPOT_LIGHTS ];\n\t#endif\n\t#if NUM_POINT_LIGHTS > 0\n\t\tuniform sampler2D pointShadowMap[ NUM_POINT_LIGHTS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHTS ];\n\t#endif\n\tfloat texture2DCompare( sampler2D depths, vec2 uv, float compare ) {\n\t\treturn step( compare, unpackRGBAToDepth( texture2D( depths, uv ) ) );\n\t}\n\tfloat texture2DShadowLerp( sampler2D depths, vec2 size, vec2 uv, float compare ) {\n\t\tconst vec2 offset = vec2( 0.0, 1.0 );\n\t\tvec2 texelSize = vec2( 1.0 ) / size;\n\t\tvec2 centroidUV = floor( uv * size + 0.5 ) / size;\n\t\tfloat lb = texture2DCompare( depths, centroidUV + texelSize * offset.xx, compare );\n\t\tfloat lt = texture2DCompare( depths, centroidUV + texelSize * offset.xy, compare );\n\t\tfloat rb = texture2DCompare( depths, centroidUV + texelSize * offset.yx, compare );\n\t\tfloat rt = texture2DCompare( depths, centroidUV + texelSize * offset.yy, compare );\n\t\tvec2 f = fract( uv * size + 0.5 );\n\t\tfloat a = mix( lb, lt, f.y );\n\t\tfloat b = mix( rb, rt, f.y );\n\t\tfloat c = mix( a, b, f.x );\n\t\treturn c;\n\t}\n\tfloat getShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord ) {\n\t\tfloat shadow = 1.0;\n\t\tshadowCoord.xyz /= shadowCoord.w;\n\t\tshadowCoord.z += shadowBias;\n\t\tbvec4 inFrustumVec = bvec4 ( shadowCoord.x >= 0.0, shadowCoord.x <= 1.0, shadowCoord.y >= 0.0, shadowCoord.y <= 1.0 );\n\t\tbool inFrustum = all( inFrustumVec );\n\t\tbvec2 frustumTestVec = bvec2( inFrustum, shadowCoord.z <= 1.0 );\n\t\tbool frustumTest = all( frustumTestVec );\n\t\tif ( frustumTest ) {\n\t\t#if defined( SHADOWMAP_TYPE_PCF )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx0 = - texelSize.x * shadowRadius;\n\t\t\tfloat dy0 = - texelSize.y * shadowRadius;\n\t\t\tfloat dx1 = + texelSize.x * shadowRadius;\n\t\t\tfloat dy1 = + texelSize.y * shadowRadius;\n\t\t\tshadow = (\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( 0.0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DCompare( shadowMap, shadowCoord.xy + vec2( dx1, dy1 ), shadowCoord.z )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#elif defined( SHADOWMAP_TYPE_PCF_SOFT )\n\t\t\tvec2 texelSize = vec2( 1.0 ) / shadowMapSize;\n\t\t\tfloat dx0 = - texelSize.x * shadowRadius;\n\t\t\tfloat dy0 = - texelSize.y * shadowRadius;\n\t\t\tfloat dx1 = + texelSize.x * shadowRadius;\n\t\t\tfloat dy1 = + texelSize.y * shadowRadius;\n\t\t\tshadow = (\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( 0.0, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx1, dy0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx0, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy, shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx1, 0.0 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( 0.0, dy1 ), shadowCoord.z ) +\n\t\t\t\ttexture2DShadowLerp( shadowMap, shadowMapSize, shadowCoord.xy + vec2( dx1, dy1 ), shadowCoord.z )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#else\n\t\t\tshadow = texture2DCompare( shadowMap, shadowCoord.xy, shadowCoord.z );\n\t\t#endif\n\t\t}\n\t\treturn shadow;\n\t}\n\tvec2 cubeToUV( vec3 v, float texelSizeY ) {\n\t\tvec3 absV = abs( v );\n\t\tfloat scaleToCube = 1.0 / max( absV.x, max( absV.y, absV.z ) );\n\t\tabsV *= scaleToCube;\n\t\tv *= scaleToCube * ( 1.0 - 2.0 * texelSizeY );\n\t\tvec2 planar = v.xy;\n\t\tfloat almostATexel = 1.5 * texelSizeY;\n\t\tfloat almostOne = 1.0 - almostATexel;\n\t\tif ( absV.z >= almostOne ) {\n\t\t\tif ( v.z > 0.0 )\n\t\t\t\tplanar.x = 4.0 - v.x;\n\t\t} else if ( absV.x >= almostOne ) {\n\t\t\tfloat signX = sign( v.x );\n\t\t\tplanar.x = v.z * signX + 2.0 * signX;\n\t\t} else if ( absV.y >= almostOne ) {\n\t\t\tfloat signY = sign( v.y );\n\t\t\tplanar.x = v.x + 2.0 * signY + 2.0;\n\t\t\tplanar.y = v.z * signY - 2.0;\n\t\t}\n\t\treturn vec2( 0.125, 0.25 ) * planar + vec2( 0.375, 0.75 );\n\t}\n\tfloat getPointShadow( sampler2D shadowMap, vec2 shadowMapSize, float shadowBias, float shadowRadius, vec4 shadowCoord, float shadowCameraNear, float shadowCameraFar ) {\n\t\tvec2 texelSize = vec2( 1.0 ) / ( shadowMapSize * vec2( 4.0, 2.0 ) );\n\t\tvec3 lightToPosition = shadowCoord.xyz;\n\t\tfloat dp = ( length( lightToPosition ) - shadowCameraNear ) / ( shadowCameraFar - shadowCameraNear );\t\tdp += shadowBias;\n\t\tvec3 bd3D = normalize( lightToPosition );\n\t\t#if defined( SHADOWMAP_TYPE_PCF ) || defined( SHADOWMAP_TYPE_PCF_SOFT )\n\t\t\tvec2 offset = vec2( - 1, 1 ) * shadowRadius * texelSize.y;\n\t\t\treturn (\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yyx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxy, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.xxx, texelSize.y ), dp ) +\n\t\t\t\ttexture2DCompare( shadowMap, cubeToUV( bd3D + offset.yxx, texelSize.y ), dp )\n\t\t\t) * ( 1.0 / 9.0 );\n\t\t#else\n\t\t\treturn texture2DCompare( shadowMap, cubeToUV( bd3D, texelSize.y ), dp );\n\t\t#endif\n\t}\n#endif\n",shadowmap_pars_vertex:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHTS > 0\n\t\tuniform mat4 directionalShadowMatrix[ NUM_DIR_LIGHTS ];\n\t\tvarying vec4 vDirectionalShadowCoord[ NUM_DIR_LIGHTS ];\n\t#endif\n\t#if NUM_SPOT_LIGHTS > 0\n\t\tuniform mat4 spotShadowMatrix[ NUM_SPOT_LIGHTS ];\n\t\tvarying vec4 vSpotShadowCoord[ NUM_SPOT_LIGHTS ];\n\t#endif\n\t#if NUM_POINT_LIGHTS > 0\n\t\tuniform mat4 pointShadowMatrix[ NUM_POINT_LIGHTS ];\n\t\tvarying vec4 vPointShadowCoord[ NUM_POINT_LIGHTS ];\n\t#endif\n#endif\n",shadowmap_vertex:"#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tvDirectionalShadowCoord[ i ] = directionalShadowMatrix[ i ] * worldPosition;\n\t}\n\t#endif\n\t#if NUM_SPOT_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tvSpotShadowCoord[ i ] = spotShadowMatrix[ i ] * worldPosition;\n\t}\n\t#endif\n\t#if NUM_POINT_LIGHTS > 0\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tvPointShadowCoord[ i ] = pointShadowMatrix[ i ] * worldPosition;\n\t}\n\t#endif\n#endif\n",shadowmask_pars_fragment:"float getShadowMask() {\n\tfloat shadow = 1.0;\n\t#ifdef USE_SHADOWMAP\n\t#if NUM_DIR_LIGHTS > 0\n\tDirectionalLight directionalLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_DIR_LIGHTS; i ++ ) {\n\t\tdirectionalLight = directionalLights[ i ];\n\t\tshadow *= bool( directionalLight.shadow ) ? getShadow( directionalShadowMap[ i ], directionalLight.shadowMapSize, directionalLight.shadowBias, directionalLight.shadowRadius, vDirectionalShadowCoord[ i ] ) : 1.0;\n\t}\n\t#endif\n\t#if NUM_SPOT_LIGHTS > 0\n\tSpotLight spotLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_SPOT_LIGHTS; i ++ ) {\n\t\tspotLight = spotLights[ i ];\n\t\tshadow *= bool( spotLight.shadow ) ? getShadow( spotShadowMap[ i ], spotLight.shadowMapSize, spotLight.shadowBias, spotLight.shadowRadius, vSpotShadowCoord[ i ] ) : 1.0;\n\t}\n\t#endif\n\t#if NUM_POINT_LIGHTS > 0\n\tPointLight pointLight;\n\t#pragma unroll_loop\n\tfor ( int i = 0; i < NUM_POINT_LIGHTS; i ++ ) {\n\t\tpointLight = pointLights[ i ];\n\t\tshadow *= bool( pointLight.shadow ) ? getPointShadow( pointShadowMap[ i ], pointLight.shadowMapSize, pointLight.shadowBias, pointLight.shadowRadius, vPointShadowCoord[ i ], pointLight.shadowCameraNear, pointLight.shadowCameraFar ) : 1.0;\n\t}\n\t#endif\n\t#endif\n\treturn shadow;\n}\n",skinbase_vertex:"#ifdef USE_SKINNING\n\tmat4 boneMatX = getBoneMatrix( skinIndex.x );\n\tmat4 boneMatY = getBoneMatrix( skinIndex.y );\n\tmat4 boneMatZ = getBoneMatrix( skinIndex.z );\n\tmat4 boneMatW = getBoneMatrix( skinIndex.w );\n#endif",skinning_pars_vertex:"#ifdef USE_SKINNING\n\tuniform mat4 bindMatrix;\n\tuniform mat4 bindMatrixInverse;\n\t#ifdef BONE_TEXTURE\n\t\tuniform sampler2D boneTexture;\n\t\tuniform int boneTextureSize;\n\t\tmat4 getBoneMatrix( const in float i ) {\n\t\t\tfloat j = i * 4.0;\n\t\t\tfloat x = mod( j, float( boneTextureSize ) );\n\t\t\tfloat y = floor( j / float( boneTextureSize ) );\n\t\t\tfloat dx = 1.0 / float( boneTextureSize );\n\t\t\tfloat dy = 1.0 / float( boneTextureSize );\n\t\t\ty = dy * ( y + 0.5 );\n\t\t\tvec4 v1 = texture2D( boneTexture, vec2( dx * ( x + 0.5 ), y ) );\n\t\t\tvec4 v2 = texture2D( boneTexture, vec2( dx * ( x + 1.5 ), y ) );\n\t\t\tvec4 v3 = texture2D( boneTexture, vec2( dx * ( x + 2.5 ), y ) );\n\t\t\tvec4 v4 = texture2D( boneTexture, vec2( dx * ( x + 3.5 ), y ) );\n\t\t\tmat4 bone = mat4( v1, v2, v3, v4 );\n\t\t\treturn bone;\n\t\t}\n\t#else\n\t\tuniform mat4 boneMatrices[ MAX_BONES ];\n\t\tmat4 getBoneMatrix( const in float i ) {\n\t\t\tmat4 bone = boneMatrices[ int(i) ];\n\t\t\treturn bone;\n\t\t}\n\t#endif\n#endif\n",skinning_vertex:"#ifdef USE_SKINNING\n\tvec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );\n\tvec4 skinned = vec4( 0.0 );\n\tskinned += boneMatX * skinVertex * skinWeight.x;\n\tskinned += boneMatY * skinVertex * skinWeight.y;\n\tskinned += boneMatZ * skinVertex * skinWeight.z;\n\tskinned += boneMatW * skinVertex * skinWeight.w;\n\ttransformed = ( bindMatrixInverse * skinned ).xyz;\n#endif\n",skinnormal_vertex:"#ifdef USE_SKINNING\n\tmat4 skinMatrix = mat4( 0.0 );\n\tskinMatrix += skinWeight.x * boneMatX;\n\tskinMatrix += skinWeight.y * boneMatY;\n\tskinMatrix += skinWeight.z * boneMatZ;\n\tskinMatrix += skinWeight.w * boneMatW;\n\tskinMatrix = bindMatrixInverse * skinMatrix * bindMatrix;\n\tobjectNormal = vec4( skinMatrix * vec4( objectNormal, 0.0 ) ).xyz;\n#endif\n",specularmap_fragment:"float specularStrength;\n#ifdef USE_SPECULARMAP\n\tvec4 texelSpecular = texture2D( specularMap, vUv );\n\tspecularStrength = texelSpecular.r;\n#else\n\tspecularStrength = 1.0;\n#endif",specularmap_pars_fragment:"#ifdef USE_SPECULARMAP\n\tuniform sampler2D specularMap;\n#endif",tonemapping_fragment:"#if defined( TONE_MAPPING )\n gl_FragColor.rgb = toneMapping( gl_FragColor.rgb );\n#endif\n",tonemapping_pars_fragment:"#ifndef saturate\n\t#define saturate(a) clamp( a, 0.0, 1.0 )\n#endif\nuniform float toneMappingExposure;\nuniform float toneMappingWhitePoint;\nvec3 LinearToneMapping( vec3 color ) {\n\treturn toneMappingExposure * color;\n}\nvec3 ReinhardToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\treturn saturate( color / ( vec3( 1.0 ) + color ) );\n}\n#define Uncharted2Helper( x ) max( ( ( x * ( 0.15 * x + 0.10 * 0.50 ) + 0.20 * 0.02 ) / ( x * ( 0.15 * x + 0.50 ) + 0.20 * 0.30 ) ) - 0.02 / 0.30, vec3( 0.0 ) )\nvec3 Uncharted2ToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\treturn saturate( Uncharted2Helper( color ) / Uncharted2Helper( vec3( toneMappingWhitePoint ) ) );\n}\nvec3 OptimizedCineonToneMapping( vec3 color ) {\n\tcolor *= toneMappingExposure;\n\tcolor = max( vec3( 0.0 ), color - 0.004 );\n\treturn pow( ( color * ( 6.2 * color + 0.5 ) ) / ( color * ( 6.2 * color + 1.7 ) + 0.06 ), vec3( 2.2 ) );\n}\n",uv_pars_fragment:"#if defined( USE_MAP ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( USE_SPECULARMAP ) || defined( USE_ALPHAMAP ) || defined( USE_EMISSIVEMAP ) || defined( USE_ROUGHNESSMAP ) || defined( USE_METALNESSMAP )\n\tvarying vec2 vUv;\n#endif",uv_pars_vertex:"#if defined( USE_MAP ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( USE_SPECULARMAP ) || defined( USE_ALPHAMAP ) || defined( USE_EMISSIVEMAP ) || defined( USE_ROUGHNESSMAP ) || defined( USE_METALNESSMAP )\n\tvarying vec2 vUv;\n\tuniform mat3 uvTransform;\n#endif\n",uv_vertex:"#if defined( USE_MAP ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP ) || defined( USE_SPECULARMAP ) || defined( USE_ALPHAMAP ) || defined( USE_EMISSIVEMAP ) || defined( USE_ROUGHNESSMAP ) || defined( USE_METALNESSMAP )\n\tvUv = ( uvTransform * vec3( uv, 1 ) ).xy;\n#endif",uv2_pars_fragment:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tvarying vec2 vUv2;\n#endif",uv2_pars_vertex:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tattribute vec2 uv2;\n\tvarying vec2 vUv2;\n#endif",uv2_vertex:"#if defined( USE_LIGHTMAP ) || defined( USE_AOMAP )\n\tvUv2 = uv2;\n#endif",worldpos_vertex:"#if defined( USE_ENVMAP ) || defined( DISTANCE ) || defined ( USE_SHADOWMAP )\n\tvec4 worldPosition = modelMatrix * vec4( transformed, 1.0 );\n#endif\n",cube_frag:"uniform samplerCube tCube;\nuniform float tFlip;\nuniform float opacity;\nvarying vec3 vWorldPosition;\nvoid main() {\n\tgl_FragColor = textureCube( tCube, vec3( tFlip * vWorldPosition.x, vWorldPosition.yz ) );\n\tgl_FragColor.a *= opacity;\n}\n",cube_vert:"varying vec3 vWorldPosition;\n#include \nvoid main() {\n\tvWorldPosition = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n\tgl_Position.z = gl_Position.w;\n}\n",depth_frag:"#if DEPTH_PACKING == 3200\n\tuniform float opacity;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#if DEPTH_PACKING == 3200\n\t\tdiffuseColor.a = opacity;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#if DEPTH_PACKING == 3200\n\t\tgl_FragColor = vec4( vec3( 1.0 - gl_FragCoord.z ), opacity );\n\t#elif DEPTH_PACKING == 3201\n\t\tgl_FragColor = packDepthToRGBA( gl_FragCoord.z );\n\t#endif\n}\n",depth_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n",distanceRGBA_frag:"#define DISTANCE\nuniform vec3 referencePosition;\nuniform float nearDistance;\nuniform float farDistance;\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main () {\n\t#include \n\tvec4 diffuseColor = vec4( 1.0 );\n\t#include \n\t#include \n\t#include \n\tfloat dist = length( vWorldPosition - referencePosition );\n\tdist = ( dist - nearDistance ) / ( farDistance - nearDistance );\n\tdist = saturate( dist );\n\tgl_FragColor = packDepthToRGBA( dist );\n}\n",distanceRGBA_vert:"#define DISTANCE\nvarying vec3 vWorldPosition;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#ifdef USE_DISPLACEMENTMAP\n\t\t#include \n\t\t#include \n\t\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvWorldPosition = worldPosition.xyz;\n}\n",equirect_frag:"uniform sampler2D tEquirect;\nvarying vec3 vWorldPosition;\n#include \nvoid main() {\n\tvec3 direction = normalize( vWorldPosition );\n\tvec2 sampleUV;\n\tsampleUV.y = asin( clamp( direction.y, - 1.0, 1.0 ) ) * RECIPROCAL_PI + 0.5;\n\tsampleUV.x = atan( direction.z, direction.x ) * RECIPROCAL_PI2 + 0.5;\n\tgl_FragColor = texture2D( tEquirect, sampleUV );\n}\n",equirect_vert:"varying vec3 vWorldPosition;\n#include \nvoid main() {\n\tvWorldPosition = transformDirection( position, modelMatrix );\n\t#include \n\t#include \n}\n",linedashed_frag:"uniform vec3 diffuse;\nuniform float opacity;\nuniform float dashSize;\nuniform float totalSize;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tif ( mod( vLineDistance, totalSize ) > dashSize ) {\n\t\tdiscard;\n\t}\n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}\n",linedashed_vert:"uniform float scale;\nattribute float lineDistance;\nvarying float vLineDistance;\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvLineDistance = scale * lineDistance;\n\tvec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );\n\tgl_Position = projectionMatrix * mvPosition;\n\t#include \n\t#include \n\t#include \n}\n",meshbasic_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\t#ifdef USE_LIGHTMAP\n\t\treflectedLight.indirectDiffuse += texture2D( lightMap, vUv2 ).xyz * lightMapIntensity;\n\t#else\n\t\treflectedLight.indirectDiffuse += vec3( 1.0 );\n\t#endif\n\t#include \n\treflectedLight.indirectDiffuse *= diffuseColor.rgb;\n\tvec3 outgoingLight = reflectedLight.indirectDiffuse;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}\n",meshbasic_vert:"#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifdef USE_ENVMAP\n\t#include \n\t#include \n\t#include \n\t#include \n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n",meshlambert_frag:"uniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float opacity;\nvarying vec3 vLightFront;\n#ifdef DOUBLE_SIDED\n\tvarying vec3 vLightBack;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\treflectedLight.indirectDiffuse = getAmbientLightIrradiance( ambientLightColor );\n\t#include \n\treflectedLight.indirectDiffuse *= BRDF_Diffuse_Lambert( diffuseColor.rgb );\n\t#ifdef DOUBLE_SIDED\n\t\treflectedLight.directDiffuse = ( gl_FrontFacing ) ? vLightFront : vLightBack;\n\t#else\n\t\treflectedLight.directDiffuse = vLightFront;\n\t#endif\n\treflectedLight.directDiffuse *= BRDF_Diffuse_Lambert( diffuseColor.rgb ) * getShadowMask();\n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + totalEmissiveRadiance;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n",meshlambert_vert:"#define LAMBERT\nvarying vec3 vLightFront;\n#ifdef DOUBLE_SIDED\n\tvarying vec3 vLightBack;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n",meshphong_frag:"#define PHONG\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform vec3 specular;\nuniform float shininess;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\t#include \n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n",meshphong_vert:"#define PHONG\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n\t#include \n}\n",meshphysical_frag:"#define PHYSICAL\nuniform vec3 diffuse;\nuniform vec3 emissive;\nuniform float roughness;\nuniform float metalness;\nuniform float opacity;\n#ifndef STANDARD\n\tuniform float clearCoat;\n\tuniform float clearCoatRoughness;\n#endif\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\tReflectedLight reflectedLight = ReflectedLight( vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ), vec3( 0.0 ) );\n\tvec3 totalEmissiveRadiance = emissive;\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvec3 outgoingLight = reflectedLight.directDiffuse + reflectedLight.indirectDiffuse + reflectedLight.directSpecular + reflectedLight.indirectSpecular + totalEmissiveRadiance;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n",meshphysical_vert:"#define PHYSICAL\nvarying vec3 vViewPosition;\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\tvViewPosition = - mvPosition.xyz;\n\t#include \n\t#include \n\t#include \n}\n",normal_frag:"#define NORMAL\nuniform float opacity;\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP )\n\tvarying vec3 vViewPosition;\n#endif\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\tgl_FragColor = vec4( packNormalToRGB( normal ), opacity );\n}\n",normal_vert:"#define NORMAL\n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP )\n\tvarying vec3 vViewPosition;\n#endif\n#ifndef FLAT_SHADED\n\tvarying vec3 vNormal;\n#endif\n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#ifndef FLAT_SHADED\n\tvNormal = normalize( transformedNormal );\n#endif\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n#if defined( FLAT_SHADED ) || defined( USE_BUMPMAP ) || defined( USE_NORMALMAP )\n\tvViewPosition = - mvPosition.xyz;\n#endif\n}\n",points_frag:"uniform vec3 diffuse;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\tvec3 outgoingLight = vec3( 0.0 );\n\tvec4 diffuseColor = vec4( diffuse, opacity );\n\t#include \n\t#include \n\t#include \n\t#include \n\toutgoingLight = diffuseColor.rgb;\n\tgl_FragColor = vec4( outgoingLight, diffuseColor.a );\n\t#include \n\t#include \n\t#include \n\t#include \n}\n",points_vert:"uniform float size;\nuniform float scale;\n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#ifdef USE_SIZEATTENUATION\n\t\tgl_PointSize = size * ( scale / - mvPosition.z );\n\t#else\n\t\tgl_PointSize = size;\n\t#endif\n\t#include \n\t#include \n\t#include \n\t#include \n}\n",shadow_frag:"uniform vec3 color;\nuniform float opacity;\n#include \n#include \n#include \n#include \n#include \n#include \n#include \nvoid main() {\n\tgl_FragColor = vec4( color, opacity * ( 1.0 - getShadowMask() ) );\n\t#include \n}\n",shadow_vert:"#include \n#include \nvoid main() {\n\t#include \n\t#include \n\t#include \n\t#include \n\t#include \n}\n"},ri={merge:function(e){for(var t={},i=0;i>16&255)/255,this.g=(e>>8&255)/255,this.b=(255&e)/255,this},setRGB:function(e,t,i){return this.r=e,this.g=t,this.b=i,this},setHSL:function(){function e(e,t,i){return i<0&&(i+=1),i>1&&(i-=1),i<1/6?e+6*(t-e)*i:i<.5?t:i<2/3?e+6*(t-e)*(2/3-i):e}return function(t,i,n){if(t=zt.euclideanModulo(t,1),i=zt.clamp(i,0,1),n=zt.clamp(n,0,1),0===i)this.r=this.g=this.b=n;else{var r=n<=.5?n*(1+i):n+i-n*i,a=2*n-r;this.r=e(a,r,t+1/3),this.g=e(a,r,t),this.b=e(a,r,t-1/3)}return this}}(),setStyle:function(e){function t(t){void 0!==t&&parseFloat(t)<1&&console.warn("THREE.Color: Alpha component of "+e+" will be ignored.")}var i;if(i=/^((?:rgb|hsl)a?)\(\s*([^\)]*)\)/.exec(e)){var n,r=i[1],a=i[2];switch(r){case"rgb":case"rgba":if(n=/^(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(,\s*([0-9]*\.?[0-9]+)\s*)?$/.exec(a))return this.r=Math.min(255,parseInt(n[1],10))/255,this.g=Math.min(255,parseInt(n[2],10))/255,this.b=Math.min(255,parseInt(n[3],10))/255,t(n[5]),this;if(n=/^(\d+)\%\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(,\s*([0-9]*\.?[0-9]+)\s*)?$/.exec(a))return this.r=Math.min(100,parseInt(n[1],10))/100,this.g=Math.min(100,parseInt(n[2],10))/100,this.b=Math.min(100,parseInt(n[3],10))/100,t(n[5]),this;break;case"hsl":case"hsla":if(n=/^([0-9]*\.?[0-9]+)\s*,\s*(\d+)\%\s*,\s*(\d+)\%\s*(,\s*([0-9]*\.?[0-9]+)\s*)?$/.exec(a)){var o=parseFloat(n[1])/360,s=parseInt(n[2],10)/100,c=parseInt(n[3],10)/100;return t(n[5]),this.setHSL(o,s,c)}}}else if(i=/^\#([A-Fa-f0-9]+)$/.exec(e)){var u,h=(u=i[1]).length;if(3===h)return this.r=parseInt(u.charAt(0)+u.charAt(0),16)/255,this.g=parseInt(u.charAt(1)+u.charAt(1),16)/255,this.b=parseInt(u.charAt(2)+u.charAt(2),16)/255,this;if(6===h)return this.r=parseInt(u.charAt(0)+u.charAt(1),16)/255,this.g=parseInt(u.charAt(2)+u.charAt(3),16)/255,this.b=parseInt(u.charAt(4)+u.charAt(5),16)/255,this}e&&e.length>0&&(void 0!==(u=ai[e])?this.setHex(u):console.warn("THREE.Color: Unknown color "+e));return this},clone:function(){return new this.constructor(this.r,this.g,this.b)},copy:function(e){return this.r=e.r,this.g=e.g,this.b=e.b,this},copyGammaToLinear:function(e,t){return void 0===t&&(t=2),this.r=Math.pow(e.r,t),this.g=Math.pow(e.g,t),this.b=Math.pow(e.b,t),this},copyLinearToGamma:function(e,t){void 0===t&&(t=2);var i=t>0?1/t:1;return this.r=Math.pow(e.r,i),this.g=Math.pow(e.g,i),this.b=Math.pow(e.b,i),this},convertGammaToLinear:function(e){return this.copyGammaToLinear(this,e),this},convertLinearToGamma:function(e){return this.copyLinearToGamma(this,e),this},getHex:function(){return 255*this.r<<16^255*this.g<<8^255*this.b<<0},getHexString:function(){return("000000"+this.getHex().toString(16)).slice(-6)},getHSL:function(e){void 0===e&&(console.warn("THREE.Color: .getHSL() target is now required"),e={h:0,s:0,l:0});var t,i,n=this.r,r=this.g,a=this.b,o=Math.max(n,r,a),s=Math.min(n,r,a),c=(s+o)/2;if(s===o)t=0,i=0;else{var u=o-s;switch(i=c<=.5?u/(o+s):u/(2-o-s),o){case n:t=(r-a)/u+(r1){for(var t=0;t1){for(var t=0;t0){n.children=[];for(s=0;s0&&(i.geometries=l),d.length>0&&(i.materials=d),p.length>0&&(i.textures=p),f.length>0&&(i.images=f),o.length>0&&(i.shapes=o)}return i.object=n,i;function g(e){var t=[];for(var i in e){var n=e[i];delete n.metadata,t.push(n)}return t}},clone:function(e){return(new this.constructor).copy(this,e)},copy:function(e,t){if(void 0===t&&(t=!0),this.name=e.name,this.up.copy(e.up),this.position.copy(e.position),this.quaternion.copy(e.quaternion),this.scale.copy(e.scale),this.matrix.copy(e.matrix),this.matrixWorld.copy(e.matrixWorld),this.matrixAutoUpdate=e.matrixAutoUpdate,this.matrixWorldNeedsUpdate=e.matrixWorldNeedsUpdate,this.layers.mask=e.layers.mask,this.visible=e.visible,this.castShadow=e.castShadow,this.receiveShadow=e.receiveShadow,this.frustumCulled=e.frustumCulled,this.renderOrder=e.renderOrder,this.userData=JSON.parse(JSON.stringify(e.userData)),!0===t)for(var i=0;it&&(t=e[i]);return t}Di.prototype=Object.assign(Object.create(u.prototype),{constructor:Di,isGeometry:!0,applyMatrix:function(e){for(var t=(new kt).getNormalMatrix(e),i=0,n=this.vertices.length;i0)for(d=0;d0&&(this.normalsNeedUpdate=!0)},computeFlatVertexNormals:function(){var e,t,i;for(this.computeFaceNormals(),e=0,t=this.faces.length;e0&&(this.normalsNeedUpdate=!0)},computeMorphNormals:function(){var e,t,i,n,r;for(i=0,n=this.faces.length;i=0;i--){var f=d[i];for(this.faces.splice(f,1),o=0,s=this.faceVertexUvs.length;o0,m=p.vertexNormals.length>0,M=1!==p.color.r||1!==p.color.g||1!==p.color.b,y=p.vertexColors.length>0,v=0;if(v=E(v,0,0),v=E(v,1,!0),v=E(v,2,!1),v=E(v,3,f),v=E(v,4,g),v=E(v,5,m),v=E(v,6,M),v=E(v,7,y),o.push(v),o.push(p.a,p.b,p.c),o.push(p.materialIndex),f){var A=this.faceVertexUvs[0][r];o.push(D(A[0]),D(A[1]),D(A[2]))}if(g&&o.push(T(p.normal)),m){var w=p.vertexNormals;o.push(T(w[0]),T(w[1]),T(w[2]))}if(M&&o.push(N(p.color)),y){var x=p.vertexColors;o.push(N(x[0]),N(x[1]),N(x[2]))}}function E(e,t,i){return i?e|1<0&&(e.data.colors=u),l.length>0&&(e.data.uvs=[l]),e.data.faces=o,e},clone:function(){return(new Di).copy(this)},copy:function(e){var t,i,n,r,a,o;this.vertices=[],this.colors=[],this.faces=[],this.faceVertexUvs=[[]],this.morphTargets=[],this.morphNormals=[],this.skinWeights=[],this.skinIndices=[],this.lineDistances=[],this.boundingBox=null,this.boundingSphere=null,this.name=e.name;var s=e.vertices;for(t=0,i=s.length;t0,o=r[1]&&r[1].length>0,s=e.morphTargets,c=s.length;if(c>0){t=[];for(var u=0;u0){h=[];for(u=0;u0?1:-1,u.push(I.x,I.y,I.z),h.push(y/g),h.push(1-v/m),L+=1}}for(v=0;v65535?Oi:ji)(e,1):this.index=e},addAttribute:function(e,t){return t&&t.isBufferAttribute||t&&t.isInterleavedBufferAttribute?"index"===e?(console.warn("THREE.BufferGeometry.addAttribute: Use .setIndex() for index attribute."),this.setIndex(t),this):(this.attributes[e]=t,this):(console.warn("THREE.BufferGeometry: .addAttribute() now expects ( name, attribute )."),this.addAttribute(e,new Li(arguments[1],arguments[2])))},getAttribute:function(e){return this.attributes[e]},removeAttribute:function(e){return delete this.attributes[e],this},addGroup:function(e,t,i){this.groups.push({start:e,count:t,materialIndex:void 0!==i?i:0})},clearGroups:function(){this.groups=[]},setDrawRange:function(e,t){this.drawRange.start=e,this.drawRange.count=t},applyMatrix:function(e){var t=this.attributes.position;void 0!==t&&(e.applyToBufferAttribute(t),t.needsUpdate=!0);var i=this.attributes.normal;void 0!==i&&((new kt).getNormalMatrix(e).applyToBufferAttribute(i),i.needsUpdate=!0);return null!==this.boundingBox&&this.computeBoundingBox(),null!==this.boundingSphere&&this.computeBoundingSphere(),this},rotateX:function(){var e=new Ut;return function(t){return e.makeRotationX(t),this.applyMatrix(e),this}}(),rotateY:function(){var e=new Ut;return function(t){return e.makeRotationY(t),this.applyMatrix(e),this}}(),rotateZ:function(){var e=new Ut;return function(t){return e.makeRotationZ(t),this.applyMatrix(e),this}}(),translate:function(){var e=new Ut;return function(t,i,n){return e.makeTranslation(t,i,n),this.applyMatrix(e),this}}(),scale:function(){var e=new Ut;return function(t,i,n){return e.makeScale(t,i,n),this.applyMatrix(e),this}}(),lookAt:function(){var e=new vi;return function(t){e.lookAt(t),e.updateMatrix(),this.applyMatrix(e.matrix)}}(),center:function(){var e=new Bt;return function(){return this.computeBoundingBox(),this.boundingBox.getCenter(e).negate(),this.translate(e.x,e.y,e.z),this}}(),setFromObject:function(e){var t=e.geometry;if(e.isPoints||e.isLine){var i=new zi(3*t.vertices.length,3),n=new zi(3*t.colors.length,3);if(this.addAttribute("position",i.copyVector3sArray(t.vertices)),this.addAttribute("color",n.copyColorsArray(t.colors)),t.lineDistances&&t.lineDistances.length===t.vertices.length){var r=new zi(t.lineDistances.length,1);this.addAttribute("lineDistance",r.copyArray(t.lineDistances))}null!==t.boundingSphere&&(this.boundingSphere=t.boundingSphere.clone()),null!==t.boundingBox&&(this.boundingBox=t.boundingBox.clone())}else e.isMesh&&t&&t.isGeometry&&this.fromGeometry(t);return this},setFromPoints:function(e){for(var t=[],i=0,n=e.length;i0){var i=new Float32Array(3*e.normals.length);this.addAttribute("normal",new Li(i,3).copyVector3sArray(e.normals))}if(e.colors.length>0){var n=new Float32Array(3*e.colors.length);this.addAttribute("color",new Li(n,3).copyColorsArray(e.colors))}if(e.uvs.length>0){var r=new Float32Array(2*e.uvs.length);this.addAttribute("uv",new Li(r,2).copyVector2sArray(e.uvs))}if(e.uvs2.length>0){var a=new Float32Array(2*e.uvs2.length);this.addAttribute("uv2",new Li(a,2).copyVector2sArray(e.uvs2))}for(var o in this.groups=e.groups,e.morphTargets){for(var s=[],c=e.morphTargets[o],u=0,h=c.length;u0){var p=new zi(4*e.skinIndices.length,4);this.addAttribute("skinIndex",p.copyVector4sArray(e.skinIndices))}if(e.skinWeights.length>0){var f=new zi(4*e.skinWeights.length,4);this.addAttribute("skinWeight",f.copyVector4sArray(e.skinWeights))}return null!==e.boundingSphere&&(this.boundingSphere=e.boundingSphere.clone()),null!==e.boundingBox&&(this.boundingBox=e.boundingBox.clone()),this},computeBoundingBox:function(){null===this.boundingBox&&(this.boundingBox=new Kt);var e=this.attributes.position;void 0!==e?this.boundingBox.setFromBufferAttribute(e):this.boundingBox.makeEmpty(),(isNaN(this.boundingBox.min.x)||isNaN(this.boundingBox.min.y)||isNaN(this.boundingBox.min.z))&&console.error('THREE.BufferGeometry.computeBoundingBox: Computed min/max have NaN values. The "position" attribute is likely to have NaN values.',this)},computeBoundingSphere:function(){var e=new Kt,t=new Bt;return function(){null===this.boundingSphere&&(this.boundingSphere=new $t);var i=this.attributes.position;if(i){var n=this.boundingSphere.center;e.setFromBufferAttribute(i),e.getCenter(n);for(var r=0,a=0,o=i.count;a0&&(e.userData=this.userData),void 0!==this.parameters){var t=this.parameters;for(var i in t)void 0!==t[i]&&(e[i]=t[i]);return e}e.data={attributes:{}};var n=this.index;if(null!==n){var r=Array.prototype.slice.call(n.array);e.data.index={type:n.array.constructor.name,array:r}}var a=this.attributes;for(var i in a){var o=a[i];r=Array.prototype.slice.call(o.array);e.data.attributes[i]={itemSize:o.itemSize,type:o.array.constructor.name,array:r,normalized:o.normalized}}var s=this.groups;s.length>0&&(e.data.groups=JSON.parse(JSON.stringify(s)));var c=this.boundingSphere;return null!==c&&(e.data.boundingSphere={center:c.center.toArray(),radius:c.radius}),e},clone:function(){return(new ki).copy(this)},copy:function(e){var t,i,n;this.index=null,this.attributes={},this.morphAttributes={},this.groups=[],this.boundingBox=null,this.boundingSphere=null,this.name=e.name;var r=e.index;null!==r&&this.setIndex(r.clone());var a=e.attributes;for(t in a){var o=a[t];this.addAttribute(t,o.clone())}var s=e.morphAttributes;for(t in s){var c=[],u=s[t];for(i=0,n=u.length;i0&&e.getShaderPrecisionFormat(e.FRAGMENT_SHADER,e.HIGH_FLOAT).precision>0)return"highp";t="mediump"}return"mediump"===t&&e.getShaderPrecisionFormat(e.VERTEX_SHADER,e.MEDIUM_FLOAT).precision>0&&e.getShaderPrecisionFormat(e.FRAGMENT_SHADER,e.MEDIUM_FLOAT).precision>0?"mediump":"lowp"}var a=void 0!==i.precision?i.precision:"highp",o=r(a);o!==a&&(console.warn("THREE.WebGLRenderer:",a,"not supported, using",o,"instead."),a=o);var s=!0===i.logarithmicDepthBuffer,c=e.getParameter(e.MAX_TEXTURE_IMAGE_UNITS),u=e.getParameter(e.MAX_VERTEX_TEXTURE_IMAGE_UNITS),h=e.getParameter(e.MAX_TEXTURE_SIZE),l=e.getParameter(e.MAX_CUBE_MAP_TEXTURE_SIZE),d=e.getParameter(e.MAX_VERTEX_ATTRIBS),p=e.getParameter(e.MAX_VERTEX_UNIFORM_VECTORS),f=e.getParameter(e.MAX_VARYING_VECTORS),g=e.getParameter(e.MAX_FRAGMENT_UNIFORM_VECTORS),m=u>0,M=!!t.get("OES_texture_float");return{getMaxAnisotropy:function(){if(void 0!==n)return n;var i=t.get("EXT_texture_filter_anisotropic");return n=null!==i?e.getParameter(i.MAX_TEXTURE_MAX_ANISOTROPY_EXT):0},getMaxPrecision:r,precision:a,logarithmicDepthBuffer:s,maxTextures:c,maxVertexTextures:u,maxTextureSize:h,maxCubemapSize:l,maxAttributes:d,maxVertexUniforms:p,maxVaryings:f,maxFragmentUniforms:g,vertexTextures:m,floatFragmentTextures:M,floatVertexTextures:m&&M}}function hn(){var e=this,t=null,i=0,n=!1,r=!1,a=new ei,o=new kt,s={value:null,needsUpdate:!1};function c(){s.value!==t&&(s.value=t,s.needsUpdate=i>0),e.numPlanes=i,e.numIntersection=0}function u(t,i,n,r){var c=null!==t?t.length:0,u=null;if(0!==c){if(u=s.value,!0!==r||null===u){var h=n+4*c,l=i.matrixWorldInverse;o.getNormalMatrix(l),(null===u||u.length65535?Oi:ji)(o,1),t.update(n,e.ELEMENT_ARRAY_BUFFER),r[i.id]=n,n}}}function pn(e,t,i){var n,r,a;this.setMode=function(e){n=e},this.setIndex=function(e){r=e.type,a=e.bytesPerElement},this.render=function(t,o){e.drawElements(n,o,r,t*a),i.update(o,n)},this.renderInstances=function(e,o,s){var c=t.get("ANGLE_instanced_arrays");null!==c?(c.drawElementsInstancedANGLE(n,s,r,o*a,e.maxInstancedCount),i.update(s,n,e.maxInstancedCount)):console.error("THREE.WebGLIndexedBufferRenderer: using THREE.InstancedBufferGeometry but hardware does not support extension ANGLE_instanced_arrays.")}}function fn(e){var t={frame:0,calls:0,triangles:0,points:0,lines:0};return{memory:{geometries:0,textures:0},render:t,programs:null,autoReset:!0,reset:function(){t.frame++,t.calls=0,t.triangles=0,t.points=0,t.lines=0},update:function(i,n,r){switch(r=r||1,t.calls++,n){case e.TRIANGLES:t.triangles+=r*(i/3);break;case e.TRIANGLE_STRIP:case e.TRIANGLE_FAN:t.triangles+=r*(i-2);break;case e.LINES:t.lines+=r*(i/2);break;case e.LINE_STRIP:t.lines+=r*(i-1);break;case e.LINE_LOOP:t.lines+=r*i;break;case e.POINTS:t.points+=r*i;break;default:console.error("THREE.WebGLInfo: Unknown draw mode:",n)}}}}function gn(e,t){return Math.abs(t[1])-Math.abs(e[1])}function mn(e){var t={},i=new Float32Array(8);return{update:function(n,r,a,o){var s=n.morphTargetInfluences,c=s.length,u=t[r.id];if(void 0===u){u=[];for(var h=0;h0&&(i.alphaTest=this.alphaTest),!0===this.premultipliedAlpha&&(i.premultipliedAlpha=this.premultipliedAlpha),!0===this.wireframe&&(i.wireframe=this.wireframe),this.wireframeLinewidth>1&&(i.wireframeLinewidth=this.wireframeLinewidth),"round"!==this.wireframeLinecap&&(i.wireframeLinecap=this.wireframeLinecap),"round"!==this.wireframeLinejoin&&(i.wireframeLinejoin=this.wireframeLinejoin),!0===this.morphTargets&&(i.morphTargets=!0),!0===this.skinning&&(i.skinning=!0),!1===this.visible&&(i.visible=!1),"{}"!==JSON.stringify(this.userData)&&(i.userData=this.userData),t){var r=n(e.textures),a=n(e.images);r.length>0&&(i.textures=r),a.length>0&&(i.images=a)}return i},clone:function(){return(new this.constructor).copy(this)},copy:function(e){this.name=e.name,this.fog=e.fog,this.lights=e.lights,this.blending=e.blending,this.side=e.side,this.flatShading=e.flatShading,this.vertexColors=e.vertexColors,this.opacity=e.opacity,this.transparent=e.transparent,this.blendSrc=e.blendSrc,this.blendDst=e.blendDst,this.blendEquation=e.blendEquation,this.blendSrcAlpha=e.blendSrcAlpha,this.blendDstAlpha=e.blendDstAlpha,this.blendEquationAlpha=e.blendEquationAlpha,this.depthFunc=e.depthFunc,this.depthTest=e.depthTest,this.depthWrite=e.depthWrite,this.colorWrite=e.colorWrite,this.precision=e.precision,this.polygonOffset=e.polygonOffset,this.polygonOffsetFactor=e.polygonOffsetFactor,this.polygonOffsetUnits=e.polygonOffsetUnits,this.dithering=e.dithering,this.alphaTest=e.alphaTest,this.premultipliedAlpha=e.premultipliedAlpha,this.overdraw=e.overdraw,this.visible=e.visible,this.userData=JSON.parse(JSON.stringify(e.userData)),this.clipShadows=e.clipShadows,this.clipIntersection=e.clipIntersection;var t=e.clippingPlanes,i=null;if(null!==t){var n=t.length;i=new Array(n);for(var r=0;r!==n;++r)i[r]=t[r].clone()}return this.clippingPlanes=i,this.shadowSide=e.shadowSide,this},dispose:function(){this.dispatchEvent({type:"dispose"})}}),en.prototype=Object.create($i.prototype),en.prototype.constructor=en,en.prototype.isMeshBasicMaterial=!0,en.prototype.copy=function(e){return $i.prototype.copy.call(this,e),this.color.copy(e.color),this.map=e.map,this.lightMap=e.lightMap,this.lightMapIntensity=e.lightMapIntensity,this.aoMap=e.aoMap,this.aoMapIntensity=e.aoMapIntensity,this.specularMap=e.specularMap,this.alphaMap=e.alphaMap,this.envMap=e.envMap,this.combine=e.combine,this.reflectivity=e.reflectivity,this.refractionRatio=e.refractionRatio,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.wireframeLinecap=e.wireframeLinecap,this.wireframeLinejoin=e.wireframeLinejoin,this.skinning=e.skinning,this.morphTargets=e.morphTargets,this},tn.prototype=Object.create($i.prototype),tn.prototype.constructor=tn,tn.prototype.isShaderMaterial=!0,tn.prototype.copy=function(e){return $i.prototype.copy.call(this,e),this.fragmentShader=e.fragmentShader,this.vertexShader=e.vertexShader,this.uniforms=ri.clone(e.uniforms),this.defines=Object.assign({},e.defines),this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.lights=e.lights,this.clipping=e.clipping,this.skinning=e.skinning,this.morphTargets=e.morphTargets,this.morphNormals=e.morphNormals,this.extensions=e.extensions,this},tn.prototype.toJSON=function(e){var t=$i.prototype.toJSON.call(this,e);return t.uniforms=this.uniforms,t.vertexShader=this.vertexShader,t.fragmentShader=this.fragmentShader,t},Object.assign(nn.prototype,{set:function(e,t){return this.origin.copy(e),this.direction.copy(t),this},clone:function(){return(new this.constructor).copy(this)},copy:function(e){return this.origin.copy(e.origin),this.direction.copy(e.direction),this},at:function(e,t){return void 0===t&&(console.warn("THREE.Ray: .at() target is now required"),t=new Bt),t.copy(this.direction).multiplyScalar(e).add(this.origin)},lookAt:function(e){return this.direction.copy(e).sub(this.origin).normalize(),this},recast:function(){var e=new Bt;return function(t){return this.origin.copy(this.at(t,e)),this}}(),closestPointToPoint:function(e,t){void 0===t&&(console.warn("THREE.Ray: .closestPointToPoint() target is now required"),t=new Bt),t.subVectors(e,this.origin);var i=t.dot(this.direction);return i<0?t.copy(this.origin):t.copy(this.direction).multiplyScalar(i).add(this.origin)},distanceToPoint:function(e){return Math.sqrt(this.distanceSqToPoint(e))},distanceSqToPoint:function(){var e=new Bt;return function(t){var i=e.subVectors(t,this.origin).dot(this.direction);return i<0?this.origin.distanceToSquared(t):(e.copy(this.direction).multiplyScalar(i).add(this.origin),e.distanceToSquared(t))}}(),distanceSqToSegment:(Hi=new Bt,Wi=new Bt,Xi=new Bt,function(e,t,i,n){Hi.copy(e).add(t).multiplyScalar(.5),Wi.copy(t).sub(e).normalize(),Xi.copy(this.origin).sub(Hi);var r,a,o,s,c=.5*e.distanceTo(t),u=-this.direction.dot(Wi),h=Xi.dot(this.direction),l=-Xi.dot(Wi),d=Xi.lengthSq(),p=Math.abs(1-u*u);if(p>0)if(a=u*h-l,s=c*p,(r=u*l-h)>=0)if(a>=-s)if(a<=s){var f=1/p;o=(r*=f)*(r+u*(a*=f)+2*h)+a*(u*r+a+2*l)+d}else a=c,o=-(r=Math.max(0,-(u*a+h)))*r+a*(a+2*l)+d;else a=-c,o=-(r=Math.max(0,-(u*a+h)))*r+a*(a+2*l)+d;else a<=-s?o=-(r=Math.max(0,-(-u*c+h)))*r+(a=r>0?-c:Math.min(Math.max(-c,-l),c))*(a+2*l)+d:a<=s?(r=0,o=(a=Math.min(Math.max(-c,-l),c))*(a+2*l)+d):o=-(r=Math.max(0,-(u*c+h)))*r+(a=r>0?c:Math.min(Math.max(-c,-l),c))*(a+2*l)+d;else a=u>0?-c:c,o=-(r=Math.max(0,-(u*a+h)))*r+a*(a+2*l)+d;return i&&i.copy(this.direction).multiplyScalar(r).add(this.origin),n&&n.copy(Wi).multiplyScalar(a).add(Hi),o}),intersectSphere:function(){var e=new Bt;return function(t,i){e.subVectors(t.center,this.origin);var n=e.dot(this.direction),r=e.dot(e)-n*n,a=t.radius*t.radius;if(r>a)return null;var o=Math.sqrt(a-r),s=n-o,c=n+o;return s<0&&c<0?null:s<0?this.at(c,i):this.at(s,i)}}(),intersectsSphere:function(e){return this.distanceToPoint(e.center)<=e.radius},distanceToPlane:function(e){var t=e.normal.dot(this.direction);if(0===t)return 0===e.distanceToPoint(this.origin)?0:null;var i=-(this.origin.dot(e.normal)+e.constant)/t;return i>=0?i:null},intersectPlane:function(e,t){var i=this.distanceToPlane(e);return null===i?null:this.at(i,t)},intersectsPlane:function(e){var t=e.distanceToPoint(this.origin);return 0===t||e.normal.dot(this.direction)*t<0},intersectBox:function(e,t){var i,n,r,a,o,s,c=1/this.direction.x,u=1/this.direction.y,h=1/this.direction.z,l=this.origin;return c>=0?(i=(e.min.x-l.x)*c,n=(e.max.x-l.x)*c):(i=(e.max.x-l.x)*c,n=(e.min.x-l.x)*c),u>=0?(r=(e.min.y-l.y)*u,a=(e.max.y-l.y)*u):(r=(e.max.y-l.y)*u,a=(e.min.y-l.y)*u),i>a||r>n?null:((r>i||i!=i)&&(i=r),(a=0?(o=(e.min.z-l.z)*h,s=(e.max.z-l.z)*h):(o=(e.max.z-l.z)*h,s=(e.min.z-l.z)*h),i>s||o>n?null:((o>i||i!=i)&&(i=o),(s=0?i:n,t)))},intersectsBox:(Vi=new Bt,function(e){return null!==this.intersectBox(e,Vi)}),intersectTriangle:function(){var e=new Bt,t=new Bt,i=new Bt,n=new Bt;return function(r,a,o,s,c){t.subVectors(a,r),i.subVectors(o,r),n.crossVectors(t,i);var u,h=this.direction.dot(n);if(h>0){if(s)return null;u=1}else{if(!(h<0))return null;u=-1,h=-h}e.subVectors(this.origin,r);var l=u*this.direction.dot(i.crossVectors(e,i));if(l<0)return null;var d=u*this.direction.dot(t.cross(e));if(d<0)return null;if(l+d>h)return null;var p=-u*e.dot(n);return p<0?null:this.at(p/h,c)}}(),applyMatrix4:function(e){return this.origin.applyMatrix4(e),this.direction.transformDirection(e),this},equals:function(e){return e.origin.equals(this.origin)&&e.direction.equals(this.direction)}}),Object.assign(rn.prototype,{set:function(e,t){return this.start.copy(e),this.end.copy(t),this},clone:function(){return(new this.constructor).copy(this)},copy:function(e){return this.start.copy(e.start),this.end.copy(e.end),this},getCenter:function(e){return void 0===e&&(console.warn("THREE.Line3: .getCenter() target is now required"),e=new Bt),e.addVectors(this.start,this.end).multiplyScalar(.5)},delta:function(e){return void 0===e&&(console.warn("THREE.Line3: .delta() target is now required"),e=new Bt),e.subVectors(this.end,this.start)},distanceSq:function(){return this.start.distanceToSquared(this.end)},distance:function(){return this.start.distanceTo(this.end)},at:function(e,t){return void 0===t&&(console.warn("THREE.Line3: .at() target is now required"),t=new Bt),this.delta(t).multiplyScalar(e).add(this.start)},closestPointToPointParameter:(Zi=new Bt,qi=new Bt,function(e,t){Zi.subVectors(e,this.start),qi.subVectors(this.end,this.start);var i=qi.dot(qi),n=qi.dot(Zi)/i;return t&&(n=zt.clamp(n,0,1)),n}),closestPointToPoint:function(e,t,i){var n=this.closestPointToPointParameter(e,t);return void 0===i&&(console.warn("THREE.Line3: .closestPointToPoint() target is now required"),i=new Bt),this.delta(i).multiplyScalar(n).add(this.start)},applyMatrix4:function(e){return this.start.applyMatrix4(e),this.end.applyMatrix4(e),this},equals:function(e){return e.start.equals(this.start)&&e.end.equals(this.end)}}),Object.assign(an,{getNormal:(Ji=new Bt,function(e,t,i,n){void 0===n&&(console.warn("THREE.Triangle: .getNormal() target is now required"),n=new Bt),n.subVectors(i,t),Ji.subVectors(e,t),n.cross(Ji);var r=n.lengthSq();return r>0?n.multiplyScalar(1/Math.sqrt(r)):n.set(0,0,0)}),getBarycoord:function(){var e=new Bt,t=new Bt,i=new Bt;return function(n,r,a,o,s){e.subVectors(o,r),t.subVectors(a,r),i.subVectors(n,r);var c=e.dot(e),u=e.dot(t),h=e.dot(i),l=t.dot(t),d=t.dot(i),p=c*l-u*u;if(void 0===s&&(console.warn("THREE.Triangle: .getBarycoord() target is now required"),s=new Bt),0===p)return s.set(-2,-1,-1);var f=1/p,g=(l*h-u*d)*f,m=(c*d-u*h)*f;return s.set(1-g-m,m,g)}}(),containsPoint:function(){var e=new Bt;return function(t,i,n,r){return an.getBarycoord(t,i,n,r,e),e.x>=0&&e.y>=0&&e.x+e.y<=1}}()}),Object.assign(an.prototype,{set:function(e,t,i){return this.a.copy(e),this.b.copy(t),this.c.copy(i),this},setFromPointsAndIndices:function(e,t,i,n){return this.a.copy(e[t]),this.b.copy(e[i]),this.c.copy(e[n]),this},clone:function(){return(new this.constructor).copy(this)},copy:function(e){return this.a.copy(e.a),this.b.copy(e.b),this.c.copy(e.c),this},getArea:function(){var e=new Bt,t=new Bt;return function(){return e.subVectors(this.c,this.b),t.subVectors(this.a,this.b),.5*e.cross(t).length()}}(),getMidpoint:function(e){return void 0===e&&(console.warn("THREE.Triangle: .getMidpoint() target is now required"),e=new Bt),e.addVectors(this.a,this.b).add(this.c).multiplyScalar(1/3)},getNormal:function(e){return an.getNormal(this.a,this.b,this.c,e)},getPlane:function(e){return void 0===e&&(console.warn("THREE.Triangle: .getPlane() target is now required"),e=new Bt),e.setFromCoplanarPoints(this.a,this.b,this.c)},getBarycoord:function(e,t){return an.getBarycoord(e,this.a,this.b,this.c,t)},containsPoint:function(e){return an.containsPoint(e,this.a,this.b,this.c)},intersectsBox:function(e){return e.intersectsTriangle(this)},closestPointToPoint:function(){var e=new ei,t=[new rn,new rn,new rn],i=new Bt,n=new Bt;return function(r,a){void 0===a&&(console.warn("THREE.Triangle: .closestPointToPoint() target is now required"),a=new Bt);var o=1/0;if(e.setFromCoplanarPoints(this.a,this.b,this.c),e.projectPoint(r,i),!0===this.containsPoint(i))a.copy(i);else{t[0].set(this.a,this.b),t[1].set(this.b,this.c),t[2].set(this.c,this.a);for(var s=0;s0){var o=r[a[0]];if(void 0!==o)for(this.morphTargetInfluences=[],this.morphTargetDictionary={},e=0,t=o.length;e0)for(this.morphTargetInfluences=[],this.morphTargetDictionary={},e=0,t=s.length;ei.far?null:{distance:c,point:f.clone(),object:e}}function M(e,t,i,o,s,c,d,f){n.fromBufferAttribute(o,c),r.fromBufferAttribute(o,d),a.fromBufferAttribute(o,f);var M=m(e,e.material,t,i,n,r,a,p);if(M){s&&(u.fromBufferAttribute(s,c),h.fromBufferAttribute(s,d),l.fromBufferAttribute(s,f),M.uv=g(p,n,r,a,u,h,l));var y=new xi(c,d,f);an.getNormal(n,r,a,y.normal),M.face=y}return M}return function(d,f){var y,v=this.geometry,A=this.material,w=this.matrixWorld;if(void 0!==A&&(null===v.boundingSphere&&v.computeBoundingSphere(),i.copy(v.boundingSphere),i.applyMatrix4(w),!1!==d.ray.intersectsSphere(i)&&(e.getInverse(w),t.copy(d.ray).applyMatrix4(e),null===v.boundingBox||!1!==t.intersectsBox(v.boundingBox))))if(v.isBufferGeometry){var x,E,T,N,D,L=v.index,b=v.attributes.position,I=v.attributes.uv;if(null!==L)for(N=0,D=L.count;N0&&(C=U);for(var P=0,B=R.length;P0)return e;var r=t*i,a=xn[r];if(void 0===a&&(a=new Float32Array(r),xn[r]=a),0!==t){n.toArray(a,0);for(var o=1,s=0;o!==t;++o)s+=i,e[o].toArray(a,s)}return a}function bn(e,t){if(e.length!==t.length)return!1;for(var i=0,n=e.length;i/gm,function(e,t){var i=ni[t];if(void 0===i)throw new Error("Can not resolve #include <"+t+">");return gr(i)})}function mr(e){return e.replace(/#pragma unroll_loop[\s]+?for \( int i \= (\d+)\; i < (\d+)\; i \+\+ \) \{([\s\S]+?)(?=\})\}/g,function(e,t,i,n){for(var r="",a=parseInt(t);a0?e.gammaFactor:1,A=function(e,t,i){return[(e=e||{}).derivatives||t.envMapCubeUV||t.bumpMap||t.normalMap||t.flatShading?"#extension GL_OES_standard_derivatives : enable":"",(e.fragDepth||t.logarithmicDepthBuffer)&&i.get("EXT_frag_depth")?"#extension GL_EXT_frag_depth : enable":"",e.drawBuffers&&i.get("WEBGL_draw_buffers")?"#extension GL_EXT_draw_buffers : require":"",(e.shaderTextureLOD||t.envMap)&&i.get("EXT_shader_texture_lod")?"#extension GL_EXT_shader_texture_lod : enable":""].filter(dr).join("\n")}(n.extensions,a,t),w=function(e){var t=[];for(var i in e){var n=e[i];!1!==n&&t.push("#define "+i+" "+n)}return t.join("\n")}(s),x=o.createProgram();n.isRawShaderMaterial?((f=[w].filter(dr).join("\n")).length>0&&(f+="\n"),(g=[A,w].filter(dr).join("\n")).length>0&&(g+="\n")):(f=["precision "+a.precision+" float;","precision "+a.precision+" int;","#define SHADER_NAME "+r.name,w,a.supportsVertexTextures?"#define VERTEX_TEXTURES":"","#define GAMMA_FACTOR "+v,"#define MAX_BONES "+a.maxBones,a.useFog&&a.fog?"#define USE_FOG":"",a.useFog&&a.fogExp?"#define FOG_EXP2":"",a.map?"#define USE_MAP":"",a.envMap?"#define USE_ENVMAP":"",a.envMap?"#define "+d:"",a.lightMap?"#define USE_LIGHTMAP":"",a.aoMap?"#define USE_AOMAP":"",a.emissiveMap?"#define USE_EMISSIVEMAP":"",a.bumpMap?"#define USE_BUMPMAP":"",a.normalMap?"#define USE_NORMALMAP":"",a.displacementMap&&a.supportsVertexTextures?"#define USE_DISPLACEMENTMAP":"",a.specularMap?"#define USE_SPECULARMAP":"",a.roughnessMap?"#define USE_ROUGHNESSMAP":"",a.metalnessMap?"#define USE_METALNESSMAP":"",a.alphaMap?"#define USE_ALPHAMAP":"",a.vertexColors?"#define USE_COLOR":"",a.flatShading?"#define FLAT_SHADED":"",a.skinning?"#define USE_SKINNING":"",a.useVertexTexture?"#define BONE_TEXTURE":"",a.morphTargets?"#define USE_MORPHTARGETS":"",a.morphNormals&&!1===a.flatShading?"#define USE_MORPHNORMALS":"",a.doubleSided?"#define DOUBLE_SIDED":"",a.flipSided?"#define FLIP_SIDED":"",a.shadowMapEnabled?"#define USE_SHADOWMAP":"",a.shadowMapEnabled?"#define "+h:"",a.sizeAttenuation?"#define USE_SIZEATTENUATION":"",a.logarithmicDepthBuffer?"#define USE_LOGDEPTHBUF":"",a.logarithmicDepthBuffer&&t.get("EXT_frag_depth")?"#define USE_LOGDEPTHBUF_EXT":"","uniform mat4 modelMatrix;","uniform mat4 modelViewMatrix;","uniform mat4 projectionMatrix;","uniform mat4 viewMatrix;","uniform mat3 normalMatrix;","uniform vec3 cameraPosition;","attribute vec3 position;","attribute vec3 normal;","attribute vec2 uv;","#ifdef USE_COLOR","\tattribute vec3 color;","#endif","#ifdef USE_MORPHTARGETS","\tattribute vec3 morphTarget0;","\tattribute vec3 morphTarget1;","\tattribute vec3 morphTarget2;","\tattribute vec3 morphTarget3;","\t#ifdef USE_MORPHNORMALS","\t\tattribute vec3 morphNormal0;","\t\tattribute vec3 morphNormal1;","\t\tattribute vec3 morphNormal2;","\t\tattribute vec3 morphNormal3;","\t#else","\t\tattribute vec3 morphTarget4;","\t\tattribute vec3 morphTarget5;","\t\tattribute vec3 morphTarget6;","\t\tattribute vec3 morphTarget7;","\t#endif","#endif","#ifdef USE_SKINNING","\tattribute vec4 skinIndex;","\tattribute vec4 skinWeight;","#endif","\n"].filter(dr).join("\n"),g=[A,"precision "+a.precision+" float;","precision "+a.precision+" int;","#define SHADER_NAME "+r.name,w,a.alphaTest?"#define ALPHATEST "+a.alphaTest+(a.alphaTest%1?"":".0"):"","#define GAMMA_FACTOR "+v,a.useFog&&a.fog?"#define USE_FOG":"",a.useFog&&a.fogExp?"#define FOG_EXP2":"",a.map?"#define USE_MAP":"",a.envMap?"#define USE_ENVMAP":"",a.envMap?"#define "+l:"",a.envMap?"#define "+d:"",a.envMap?"#define "+p:"",a.lightMap?"#define USE_LIGHTMAP":"",a.aoMap?"#define USE_AOMAP":"",a.emissiveMap?"#define USE_EMISSIVEMAP":"",a.bumpMap?"#define USE_BUMPMAP":"",a.normalMap?"#define USE_NORMALMAP":"",a.specularMap?"#define USE_SPECULARMAP":"",a.roughnessMap?"#define USE_ROUGHNESSMAP":"",a.metalnessMap?"#define USE_METALNESSMAP":"",a.alphaMap?"#define USE_ALPHAMAP":"",a.vertexColors?"#define USE_COLOR":"",a.gradientMap?"#define USE_GRADIENTMAP":"",a.flatShading?"#define FLAT_SHADED":"",a.doubleSided?"#define DOUBLE_SIDED":"",a.flipSided?"#define FLIP_SIDED":"",a.shadowMapEnabled?"#define USE_SHADOWMAP":"",a.shadowMapEnabled?"#define "+h:"",a.premultipliedAlpha?"#define PREMULTIPLIED_ALPHA":"",a.physicallyCorrectLights?"#define PHYSICALLY_CORRECT_LIGHTS":"",a.logarithmicDepthBuffer?"#define USE_LOGDEPTHBUF":"",a.logarithmicDepthBuffer&&t.get("EXT_frag_depth")?"#define USE_LOGDEPTHBUF_EXT":"",a.envMap&&t.get("EXT_shader_texture_lod")?"#define TEXTURE_LOD_EXT":"","uniform mat4 viewMatrix;","uniform vec3 cameraPosition;",a.toneMapping!==de?"#define TONE_MAPPING":"",a.toneMapping!==de?ni.tonemapping_pars_fragment:"",a.toneMapping!==de?lr("toneMapping",a.toneMapping):"",a.dithering?"#define DITHERING":"",a.outputEncoding||a.mapEncoding||a.envMapEncoding||a.emissiveMapEncoding?ni.encodings_pars_fragment:"",a.mapEncoding?hr("mapTexelToLinear",a.mapEncoding):"",a.envMapEncoding?hr("envMapTexelToLinear",a.envMapEncoding):"",a.emissiveMapEncoding?hr("emissiveMapTexelToLinear",a.emissiveMapEncoding):"",a.outputEncoding?(m="linearToOutputTexel",M=a.outputEncoding,y=ur(M),"vec4 "+m+"( vec4 value ) { return LinearTo"+y[0]+y[1]+"; }"):"",a.depthPacking?"#define DEPTH_PACKING "+n.depthPacking:"","\n"].filter(dr).join("\n")),c=fr(c=pr(c=gr(c),a),a),u=fr(u=pr(u=gr(u),a),a);var E=f+(c=mr(c)),T=g+(u=mr(u)),L=sr(o,o.VERTEX_SHADER,E),b=sr(o,o.FRAGMENT_SHADER,T);o.attachShader(x,L),o.attachShader(x,b),void 0!==n.index0AttributeName?o.bindAttribLocation(x,0,n.index0AttributeName):!0===a.morphTargets&&o.bindAttribLocation(x,0,"position"),o.linkProgram(x);var I,_,S=o.getProgramInfoLog(x).trim(),j=o.getShaderInfoLog(L).trim(),C=o.getShaderInfoLog(b).trim(),O=!0,z=!0;return!1===o.getProgramParameter(x,o.LINK_STATUS)?(O=!1,console.error("THREE.WebGLProgram: shader error: ",o.getError(),"gl.VALIDATE_STATUS",o.getProgramParameter(x,o.VALIDATE_STATUS),"gl.getProgramInfoLog",S,j,C)):""!==S?console.warn("THREE.WebGLProgram: gl.getProgramInfoLog()",S):""!==j&&""!==C||(z=!1),z&&(this.diagnostics={runnable:O,material:n,programLog:S,vertexShader:{log:j,prefix:f},fragmentShader:{log:C,prefix:g}}),o.deleteShader(L),o.deleteShader(b),this.getUniforms=function(){return void 0===I&&(I=new or(o,x,e)),I},this.getAttributes=function(){return void 0===_&&(_=function(e,t){for(var i={},n=e.getProgramParameter(t,e.ACTIVE_ATTRIBUTES),r=0;r0,maxBones:d,useVertexTexture:i.floatVertexTextures,morphTargets:t.morphTargets,morphNormals:t.morphNormals,maxMorphTargets:e.maxMorphTargets,maxMorphNormals:e.maxMorphNormals,numDirLights:n.directional.length,numPointLights:n.point.length,numSpotLights:n.spot.length,numRectAreaLights:n.rectArea.length,numHemiLights:n.hemi.length,numClippingPlanes:c,numClipIntersection:u,dithering:t.dithering,shadowMapEnabled:e.shadowMap.enabled&&h.receiveShadow&&a.length>0,shadowMapType:e.shadowMap.type,toneMapping:e.toneMapping,physicallyCorrectLights:e.physicallyCorrectLights,premultipliedAlpha:t.premultipliedAlpha,alphaTest:t.alphaTest,doubleSided:t.side===I,flipSided:t.side===b,depthPacking:void 0!==t.depthPacking&&t.depthPacking}},this.getProgramCode=function(t,i){var n=[];if(i.shaderID?n.push(i.shaderID):(n.push(t.fragmentShader),n.push(t.vertexShader)),void 0!==t.defines)for(var r in t.defines)n.push(r),n.push(t.defines[r]);for(var o=0;o1&&i.sort(Ar),n.length>1&&n.sort(wr)}}}function Er(){var e={};return{get:function(t,i){var n=t.id+","+i.id,r=e[n];return void 0===r&&(r=new xr,e[n]=r),r},dispose:function(){e={}}}}function Tr(){var e={};return{get:function(t){if(void 0!==e[t.id])return e[t.id];var i;switch(t.type){case"DirectionalLight":i={direction:new Bt,color:new oi,shadow:!1,shadowBias:0,shadowRadius:1,shadowMapSize:new Rt};break;case"SpotLight":i={position:new Bt,direction:new Bt,color:new oi,distance:0,coneCos:0,penumbraCos:0,decay:0,shadow:!1,shadowBias:0,shadowRadius:1,shadowMapSize:new Rt};break;case"PointLight":i={position:new Bt,color:new oi,distance:0,decay:0,shadow:!1,shadowBias:0,shadowRadius:1,shadowMapSize:new Rt,shadowCameraNear:1,shadowCameraFar:1e3};break;case"HemisphereLight":i={direction:new Bt,skyColor:new oi,groundColor:new oi};break;case"RectAreaLight":i={color:new oi,position:new Bt,halfWidth:new Bt,halfHeight:new Bt}}return e[t.id]=i,i}}}var Nr,Dr,Lr,br,Ir,_r,Sr,jr,Cr=0;function Or(){var e=new Tr,t={id:Cr++,hash:"",ambient:[0,0,0],directional:[],directionalShadowMap:[],directionalShadowMatrix:[],spot:[],spotShadowMap:[],spotShadowMatrix:[],rectArea:[],point:[],pointShadowMap:[],pointShadowMatrix:[],hemi:[]},i=new Bt,n=new Ut,r=new Ut;return{setup:function(a,o,s){for(var c=0,u=0,h=0,l=0,d=0,p=0,f=0,g=0,m=s.matrixWorldInverse,M=0,y=a.length;M0:s&&s.isGeometry&&(M=s.morphTargets&&s.morphTargets.length>0)),t.isSkinnedMesh&&!1===i.skinning&&console.warn("THREE.WebGLShadowMap: THREE.SkinnedMesh with material.skinning set to false:",t);var y=t.isSkinnedMesh&&i.skinning,v=0;M&&(v|=u),y&&(v|=h),c=l[v]}if(e.localClippingEnabled&&!0===i.clipShadows&&0!==i.clippingPlanes.length){var A=c.uuid,w=i.uuid,x=f[A];void 0===x&&(x={},f[A]=x);var E=x[w];void 0===E&&(E=c.clone(),x[w]=E),c=E}return c.visible=i.visible,c.wireframe=i.wireframe,c.side=null!=i.shadowSide?i.shadowSide:g[i.side],c.clipShadows=i.clipShadows,c.clippingPlanes=i.clippingPlanes,c.clipIntersection=i.clipIntersection,c.wireframeLinewidth=i.wireframeLinewidth,c.linewidth=i.linewidth,n&&c.isMeshDistanceMaterial&&(c.referencePosition.copy(r),c.nearDistance=a,c.farDistance=o),c}function _(i,r,a,o){if(!1!==i.visible){if(i.layers.test(r.layers)&&(i.isMesh||i.isLine||i.isPoints)&&i.castShadow&&(!i.frustumCulled||n.intersectsObject(i))){i.modelViewMatrix.multiplyMatrices(a.matrixWorldInverse,i.matrixWorld);var s=t.update(i),u=i.material;if(Array.isArray(u))for(var h=s.groups,l=0,d=h.length;l 0 ) {","\t\tfloat fogFactor = 0.0;","\t\tif ( fogType == 1 ) {","\t\t\tfogFactor = smoothstep( fogNear, fogFar, fogDepth );","\t\t} else {","\t\t\tconst float LOG2 = 1.442695;","\t\t\tfogFactor = exp2( - fogDensity * fogDensity * fogDepth * fogDepth * LOG2 );","\t\t\tfogFactor = 1.0 - clamp( fogFactor, 0.0, 1.0 );","\t\t}","\t\tgl_FragColor.rgb = mix( gl_FragColor.rgb, fogColor, fogFactor );","\t}","}"].join("\n")),t.compileShader(i),t.compileShader(n),t.attachShader(e,i),t.attachShader(e,n),t.linkProgram(e),e}(),c={position:t.getAttribLocation(s,"position"),uv:t.getAttribLocation(s,"uv")},u={uvOffset:t.getUniformLocation(s,"uvOffset"),uvScale:t.getUniformLocation(s,"uvScale"),rotation:t.getUniformLocation(s,"rotation"),center:t.getUniformLocation(s,"center"),scale:t.getUniformLocation(s,"scale"),color:t.getUniformLocation(s,"color"),map:t.getUniformLocation(s,"map"),opacity:t.getUniformLocation(s,"opacity"),modelViewMatrix:t.getUniformLocation(s,"modelViewMatrix"),projectionMatrix:t.getUniformLocation(s,"projectionMatrix"),fogType:t.getUniformLocation(s,"fogType"),fogDensity:t.getUniformLocation(s,"fogDensity"),fogNear:t.getUniformLocation(s,"fogNear"),fogFar:t.getUniformLocation(s,"fogFar"),fogColor:t.getUniformLocation(s,"fogColor"),fogDepth:t.getUniformLocation(s,"fogDepth"),alphaTest:t.getUniformLocation(s,"alphaTest")};var n=document.createElementNS("http://www.w3.org/1999/xhtml","canvas");n.width=8,n.height=8;var l=n.getContext("2d");l.fillStyle="white",l.fillRect(0,0,8,8),h=new kr(n)}function g(e,t){return e.renderOrder!==t.renderOrder?e.renderOrder-t.renderOrder:e.z!==t.z?t.z-e.z:t.id-e.id}this.render=function(r,m,M){if(0!==r.length){void 0===s&&f(),i.useProgram(s),i.initAttributes(),i.enableAttribute(c.position),i.enableAttribute(c.uv),i.disableUnusedAttributes(),i.disable(t.CULL_FACE),i.enable(t.BLEND),t.bindBuffer(t.ARRAY_BUFFER,a),t.vertexAttribPointer(c.position,2,t.FLOAT,!1,16,0),t.vertexAttribPointer(c.uv,2,t.FLOAT,!1,16,8),t.bindBuffer(t.ELEMENT_ARRAY_BUFFER,o),t.uniformMatrix4fv(u.projectionMatrix,!1,M.projectionMatrix.elements),i.activeTexture(t.TEXTURE0),t.uniform1i(u.map,0);var y=0,v=0,A=m.fog;A?(t.uniform3f(u.fogColor,A.color.r,A.color.g,A.color.b),A.isFog?(t.uniform1f(u.fogNear,A.near),t.uniform1f(u.fogFar,A.far),t.uniform1i(u.fogType,1),y=1,v=1):A.isFogExp2&&(t.uniform1f(u.fogDensity,A.density),t.uniform1i(u.fogType,2),y=2,v=2)):(t.uniform1i(u.fogType,0),y=0,v=0);for(var w=0,x=r.length;w=1):-1!==k.indexOf("OpenGL ES")&&(B=parseFloat(/^OpenGL\ ES\ ([0-9])/.exec(k)[1]),j=B>=2);var F=null,G={},Q=new Xt,Y=new Xt;function V(t,i,n){var r=new Uint8Array(4),a=e.createTexture();e.bindTexture(t,a),e.texParameteri(t,e.TEXTURE_MIN_FILTER,e.NEAREST),e.texParameteri(t,e.TEXTURE_MAG_FILTER,e.NEAREST);for(var o=0;ot||e.height>t){if("data"in e)return void console.warn("THREE.WebGLRenderer: image in DataTexture is too big ("+e.width+"x"+e.height+").");var i=t/Math.max(e.width,e.height),n=document.createElementNS("http://www.w3.org/1999/xhtml","canvas");return n.width=Math.floor(e.width*i),n.height=Math.floor(e.height*i),n.getContext("2d").drawImage(e,0,0,e.width,e.height,0,0,n.width,n.height),console.warn("THREE.WebGLRenderer: image is too big ("+e.width+"x"+e.height+"). Resized to "+n.width+"x"+n.height,e),n}return e}function l(e){return zt.isPowerOfTwo(e.width)&&zt.isPowerOfTwo(e.height)}function d(e,t){return e.generateMipmaps&&t&&e.minFilter!==Le&&e.minFilter!==_e}function p(t,i,r,a){e.generateMipmap(t),n.get(i).__maxMipLevel=Math.log(Math.max(r,a))*Math.LOG2E}function f(t){return t===Le||t===be||t===Ie?e.NEAREST:e.LINEAR}function g(t){var i=t.target;i.removeEventListener("dispose",g),function(t){var i=n.get(t);if(t.image&&i.__image__webglTextureCube)e.deleteTexture(i.__image__webglTextureCube);else{if(void 0===i.__webglInit)return;e.deleteTexture(i.__webglTexture)}n.remove(t)}(i),i.isVideoTexture&&delete u[i.id],o.memory.textures--}function m(t){var i=t.target;i.removeEventListener("dispose",m),function(t){var i=n.get(t),r=n.get(t.texture);if(!t)return;void 0!==r.__webglTexture&&e.deleteTexture(r.__webglTexture);t.depthTexture&&t.depthTexture.dispose();if(t.isWebGLRenderTargetCube)for(var a=0;a<6;a++)e.deleteFramebuffer(i.__webglFramebuffer[a]),i.__webglDepthbuffer&&e.deleteRenderbuffer(i.__webglDepthbuffer[a]);else e.deleteFramebuffer(i.__webglFramebuffer),i.__webglDepthbuffer&&e.deleteRenderbuffer(i.__webglDepthbuffer);n.remove(t.texture),n.remove(t)}(i),o.memory.textures--}function M(t,f){var m=n.get(t);if(t.isVideoTexture&&function(e){var t=e.id,i=o.render.frame;u[t]!==i&&(u[t]=i,e.update())}(t),t.version>0&&m.__version!==t.version){var M=t.image;if(void 0===M)console.warn("THREE.WebGLRenderer: Texture marked for update but image is undefined",t);else{if(!1!==M.complete)return void function(t,n,u){void 0===t.__webglInit&&(t.__webglInit=!0,n.addEventListener("dispose",g),t.__webglTexture=e.createTexture(),o.memory.textures++);i.activeTexture(e.TEXTURE0+u),i.bindTexture(e.TEXTURE_2D,t.__webglTexture),e.pixelStorei(e.UNPACK_FLIP_Y_WEBGL,n.flipY),e.pixelStorei(e.UNPACK_PREMULTIPLY_ALPHA_WEBGL,n.premultiplyAlpha),e.pixelStorei(e.UNPACK_ALIGNMENT,n.unpackAlignment);var f=h(n.image,r.maxTextureSize);(function(e){return e.wrapS!==Ne||e.wrapT!==Ne||e.minFilter!==Le&&e.minFilter!==_e})(n)&&!1===l(f)&&(f=function(e){return e instanceof HTMLImageElement||e instanceof HTMLCanvasElement||e instanceof ImageBitmap?(void 0===s&&(s=document.createElementNS("http://www.w3.org/1999/xhtml","canvas")),s.width=zt.floorPowerOfTwo(e.width),s.height=zt.floorPowerOfTwo(e.height),s.getContext("2d").drawImage(e,0,0,s.width,s.height),console.warn("THREE.WebGLRenderer: image is not power of two ("+e.width+"x"+e.height+"). Resized to "+s.width+"x"+s.height,e),s):e}(f));var m=l(f),M=a.convert(n.format),v=a.convert(n.type);y(e.TEXTURE_2D,n,m);var A,w=n.mipmaps;if(n.isDepthTexture){var x=e.DEPTH_COMPONENT;if(n.type===Be){if(!c)throw new Error("Float Depth Texture only supported in WebGL2.0");x=e.DEPTH_COMPONENT32F}else c&&(x=e.DEPTH_COMPONENT16);n.format===qe&&x===e.DEPTH_COMPONENT&&n.type!==Re&&n.type!==Pe&&(console.warn("THREE.WebGLRenderer: Use UnsignedShortType or UnsignedIntType for DepthFormat DepthTexture."),n.type=Re,v=a.convert(n.type)),n.format===Je&&(x=e.DEPTH_STENCIL,n.type!==Ye&&(console.warn("THREE.WebGLRenderer: Use UnsignedInt248Type for DepthStencilFormat DepthTexture."),n.type=Ye,v=a.convert(n.type))),i.texImage2D(e.TEXTURE_2D,0,x,f.width,f.height,0,M,v,null)}else if(n.isDataTexture)if(w.length>0&&m){for(var E=0,T=w.length;E-1?i.compressedTexImage2D(e.TEXTURE_2D,E,M,A.width,A.height,0,A.data):console.warn("THREE.WebGLRenderer: Attempt to load unsupported compressed texture format in .uploadTexture()"):i.texImage2D(e.TEXTURE_2D,E,M,A.width,A.height,0,M,v,A.data);t.__maxMipLevel=w.length-1}else if(w.length>0&&m){for(E=0,T=w.length;E1||n.get(o).__currentAnisotropy)&&(e.texParameterf(i,c.TEXTURE_MAX_ANISOTROPY_EXT,Math.min(o.anisotropy,r.getMaxAnisotropy())),n.get(o).__currentAnisotropy=o.anisotropy)}}function v(t,r,o,s){var c=a.convert(r.texture.format),u=a.convert(r.texture.type);i.texImage2D(s,0,c,r.width,r.height,0,c,u,null),e.bindFramebuffer(e.FRAMEBUFFER,t),e.framebufferTexture2D(e.FRAMEBUFFER,o,s,n.get(r.texture).__webglTexture,0),e.bindFramebuffer(e.FRAMEBUFFER,null)}function A(t,i){e.bindRenderbuffer(e.RENDERBUFFER,t),i.depthBuffer&&!i.stencilBuffer?(e.renderbufferStorage(e.RENDERBUFFER,e.DEPTH_COMPONENT16,i.width,i.height),e.framebufferRenderbuffer(e.FRAMEBUFFER,e.DEPTH_ATTACHMENT,e.RENDERBUFFER,t)):i.depthBuffer&&i.stencilBuffer?(e.renderbufferStorage(e.RENDERBUFFER,e.DEPTH_STENCIL,i.width,i.height),e.framebufferRenderbuffer(e.FRAMEBUFFER,e.DEPTH_STENCIL_ATTACHMENT,e.RENDERBUFFER,t)):e.renderbufferStorage(e.RENDERBUFFER,e.RGBA4,i.width,i.height),e.bindRenderbuffer(e.RENDERBUFFER,null)}function w(t){var i=n.get(t),r=!0===t.isWebGLRenderTargetCube;if(t.depthTexture){if(r)throw new Error("target.depthTexture not supported in Cube render targets");!function(t,i){if(i&&i.isWebGLRenderTargetCube)throw new Error("Depth Texture with cube render targets is not supported");if(e.bindFramebuffer(e.FRAMEBUFFER,t),!i.depthTexture||!i.depthTexture.isDepthTexture)throw new Error("renderTarget.depthTexture must be an instance of THREE.DepthTexture");n.get(i.depthTexture).__webglTexture&&i.depthTexture.image.width===i.width&&i.depthTexture.image.height===i.height||(i.depthTexture.image.width=i.width,i.depthTexture.image.height=i.height,i.depthTexture.needsUpdate=!0),M(i.depthTexture,0);var r=n.get(i.depthTexture).__webglTexture;if(i.depthTexture.format===qe)e.framebufferTexture2D(e.FRAMEBUFFER,e.DEPTH_ATTACHMENT,e.TEXTURE_2D,r,0);else{if(i.depthTexture.format!==Je)throw new Error("Unknown depthTexture format");e.framebufferTexture2D(e.FRAMEBUFFER,e.DEPTH_STENCIL_ATTACHMENT,e.TEXTURE_2D,r,0)}}(i.__webglFramebuffer,t)}else if(r){i.__webglDepthbuffer=[];for(var a=0;a<6;a++)e.bindFramebuffer(e.FRAMEBUFFER,i.__webglFramebuffer[a]),i.__webglDepthbuffer[a]=e.createRenderbuffer(),A(i.__webglDepthbuffer[a],t)}else e.bindFramebuffer(e.FRAMEBUFFER,i.__webglFramebuffer),i.__webglDepthbuffer=e.createRenderbuffer(),A(i.__webglDepthbuffer,t);e.bindFramebuffer(e.FRAMEBUFFER,null)}this.setTexture2D=M,this.setTextureCube=function(t,s){var c=n.get(t);if(6===t.image.length)if(t.version>0&&c.__version!==t.version){c.__image__webglTextureCube||(t.addEventListener("dispose",g),c.__image__webglTextureCube=e.createTexture(),o.memory.textures++),i.activeTexture(e.TEXTURE0+s),i.bindTexture(e.TEXTURE_CUBE_MAP,c.__image__webglTextureCube),e.pixelStorei(e.UNPACK_FLIP_Y_WEBGL,t.flipY);for(var u=t&&t.isCompressedTexture,f=t.image[0]&&t.image[0].isDataTexture,m=[],M=0;M<6;M++)m[M]=u||f?f?t.image[M].image:t.image[M]:h(t.image[M],r.maxCubemapSize);var v=m[0],A=l(v),w=a.convert(t.format),x=a.convert(t.type);y(e.TEXTURE_CUBE_MAP,t,A);for(M=0;M<6;M++)if(u)for(var E,T=m[M].mipmaps,N=0,D=T.length;N-1?i.compressedTexImage2D(e.TEXTURE_CUBE_MAP_POSITIVE_X+M,N,w,E.width,E.height,0,E.data):console.warn("THREE.WebGLRenderer: Attempt to load unsupported compressed texture format in .setTextureCube()"):i.texImage2D(e.TEXTURE_CUBE_MAP_POSITIVE_X+M,N,w,E.width,E.height,0,w,x,E.data);else f?i.texImage2D(e.TEXTURE_CUBE_MAP_POSITIVE_X+M,0,w,m[M].width,m[M].height,0,w,x,m[M].data):i.texImage2D(e.TEXTURE_CUBE_MAP_POSITIVE_X+M,0,w,w,x,m[M]);c.__maxMipLevel=u?T.length-1:0,d(t,A)&&p(e.TEXTURE_CUBE_MAP,t,v.width,v.height),c.__version=t.version,t.onUpdate&&t.onUpdate(t)}else i.activeTexture(e.TEXTURE0+s),i.bindTexture(e.TEXTURE_CUBE_MAP,c.__image__webglTextureCube)},this.setTextureCubeDynamic=function(t,r){i.activeTexture(e.TEXTURE0+r),i.bindTexture(e.TEXTURE_CUBE_MAP,n.get(t).__webglTexture)},this.setupRenderTarget=function(t){var r=n.get(t),a=n.get(t.texture);t.addEventListener("dispose",m),a.__webglTexture=e.createTexture(),o.memory.textures++;var s=!0===t.isWebGLRenderTargetCube,c=l(t);if(s){r.__webglFramebuffer=[];for(var u=0;u<6;u++)r.__webglFramebuffer[u]=e.createFramebuffer()}else r.__webglFramebuffer=e.createFramebuffer();if(s){i.bindTexture(e.TEXTURE_CUBE_MAP,a.__webglTexture),y(e.TEXTURE_CUBE_MAP,t.texture,c);for(u=0;u<6;u++)v(r.__webglFramebuffer[u],t,e.COLOR_ATTACHMENT0,e.TEXTURE_CUBE_MAP_POSITIVE_X+u);d(t.texture,c)&&p(e.TEXTURE_CUBE_MAP,t.texture,t.width,t.height),i.bindTexture(e.TEXTURE_CUBE_MAP,null)}else i.bindTexture(e.TEXTURE_2D,a.__webglTexture),y(e.TEXTURE_2D,t.texture,c),v(r.__webglFramebuffer,t,e.COLOR_ATTACHMENT0,e.TEXTURE_2D),d(t.texture,c)&&p(e.TEXTURE_2D,t.texture,t.width,t.height),i.bindTexture(e.TEXTURE_2D,null);t.depthBuffer&&w(t)},this.updateRenderTargetMipmap=function(t){var r=t.texture;if(d(r,l(t))){var a=t.isWebGLRenderTargetCube?e.TEXTURE_CUBE_MAP:e.TEXTURE_2D,o=n.get(r).__webglTexture;i.bindTexture(a,o),p(a,r,t.width,t.height),i.bindTexture(a,null)}}}function Yr(e,t){return{convert:function(i){var n;if(i===Te)return e.REPEAT;if(i===Ne)return e.CLAMP_TO_EDGE;if(i===De)return e.MIRRORED_REPEAT;if(i===Le)return e.NEAREST;if(i===be)return e.NEAREST_MIPMAP_NEAREST;if(i===Ie)return e.NEAREST_MIPMAP_LINEAR;if(i===_e)return e.LINEAR;if(i===Se)return e.LINEAR_MIPMAP_NEAREST;if(i===je)return e.LINEAR_MIPMAP_LINEAR;if(i===Ce)return e.UNSIGNED_BYTE;if(i===Fe)return e.UNSIGNED_SHORT_4_4_4_4;if(i===Ge)return e.UNSIGNED_SHORT_5_5_5_1;if(i===Qe)return e.UNSIGNED_SHORT_5_6_5;if(i===Oe)return e.BYTE;if(i===ze)return e.SHORT;if(i===Re)return e.UNSIGNED_SHORT;if(i===Ue)return e.INT;if(i===Pe)return e.UNSIGNED_INT;if(i===Be)return e.FLOAT;if(i===ke&&null!==(n=t.get("OES_texture_half_float")))return n.HALF_FLOAT_OES;if(i===Ve)return e.ALPHA;if(i===He)return e.RGB;if(i===We)return e.RGBA;if(i===Xe)return e.LUMINANCE;if(i===Ze)return e.LUMINANCE_ALPHA;if(i===qe)return e.DEPTH_COMPONENT;if(i===Je)return e.DEPTH_STENCIL;if(i===B)return e.FUNC_ADD;if(i===k)return e.FUNC_SUBTRACT;if(i===F)return e.FUNC_REVERSE_SUBTRACT;if(i===Y)return e.ZERO;if(i===V)return e.ONE;if(i===H)return e.SRC_COLOR;if(i===W)return e.ONE_MINUS_SRC_COLOR;if(i===X)return e.SRC_ALPHA;if(i===Z)return e.ONE_MINUS_SRC_ALPHA;if(i===q)return e.DST_ALPHA;if(i===J)return e.ONE_MINUS_DST_ALPHA;if(i===K)return e.DST_COLOR;if(i===$)return e.ONE_MINUS_DST_COLOR;if(i===ee)return e.SRC_ALPHA_SATURATE;if((i===Ke||i===$e||i===et||i===tt)&&null!==(n=t.get("WEBGL_compressed_texture_s3tc"))){if(i===Ke)return n.COMPRESSED_RGB_S3TC_DXT1_EXT;if(i===$e)return n.COMPRESSED_RGBA_S3TC_DXT1_EXT;if(i===et)return n.COMPRESSED_RGBA_S3TC_DXT3_EXT;if(i===tt)return n.COMPRESSED_RGBA_S3TC_DXT5_EXT}if((i===it||i===nt||i===rt||i===at)&&null!==(n=t.get("WEBGL_compressed_texture_pvrtc"))){if(i===it)return n.COMPRESSED_RGB_PVRTC_4BPPV1_IMG;if(i===nt)return n.COMPRESSED_RGB_PVRTC_2BPPV1_IMG;if(i===rt)return n.COMPRESSED_RGBA_PVRTC_4BPPV1_IMG;if(i===at)return n.COMPRESSED_RGBA_PVRTC_2BPPV1_IMG}if(i===ot&&null!==(n=t.get("WEBGL_compressed_texture_etc1")))return n.COMPRESSED_RGB_ETC1_WEBGL;if((i===st||i===ct||i===ut||i===ht||i===lt||i===dt||i===pt||i===ft||i===gt||i===mt||i===Mt||i===yt||i===vt||i===At)&&null!==(n=t.get("WEBGL_compressed_texture_astc")))return i;if((i===G||i===Q)&&null!==(n=t.get("EXT_blend_minmax"))){if(i===G)return n.MIN_EXT;if(i===Q)return n.MAX_EXT}return i===Ye&&null!==(n=t.get("WEBGL_depth_texture"))?n.UNSIGNED_INT_24_8_WEBGL:0}}}function Vr(e,t,i,n){Ai.call(this),this.type="PerspectiveCamera",this.fov=void 0!==e?e:50,this.zoom=1,this.near=void 0!==i?i:.1,this.far=void 0!==n?n:2e3,this.focus=10,this.aspect=void 0!==t?t:1,this.view=null,this.filmGauge=35,this.filmOffset=0,this.updateProjectionMatrix()}function Hr(e){Vr.call(this),this.cameras=e||[]}function Wr(e){var t=this,i=null,n=null,r=null,a=new Ut,o=new Ut;"undefined"!=typeof window&&"VRFrameData"in window&&(n=new window.VRFrameData,window.addEventListener("vrdisplaypresentchange",m,!1));var s=new Ut,c=new Pt,u=new Bt,h=new Vr;h.bounds=new Xt(0,0,.5,1),h.layers.enable(1);var l=new Vr;l.bounds=new Xt(.5,0,.5,1),l.layers.enable(2);var d,p,f=new Hr([h,l]);function g(){return null!==i&&!0===i.isPresenting}function m(){if(g()){var n=i.getEyeParameters("left"),r=n.renderWidth,a=n.renderHeight;p=e.getPixelRatio(),d=e.getSize(),e.setDrawingBufferSize(2*r,a,1),M.start()}else t.enabled&&(e.setDrawingBufferSize(d.width,d.height,p),M.stop())}f.layers.enable(1),f.layers.enable(2),this.enabled=!1,this.userHeight=1.6,this.getDevice=function(){return i},this.setDevice=function(e){void 0!==e&&(i=e),M.setContext(e)},this.setPoseTarget=function(e){void 0!==e&&(r=e)},this.getCamera=function(e){if(null===i)return e;i.depthNear=e.near,i.depthFar=e.far,i.getFrameData(n);var d=i.stageParameters;d?a.fromArray(d.sittingToStandingTransform):a.makeTranslation(0,t.userHeight,0);var p=n.pose,g=null!==r?r:e;if(g.matrix.copy(a),g.matrix.decompose(g.position,g.quaternion,g.scale),null!==p.orientation&&(c.fromArray(p.orientation),g.quaternion.multiply(c)),null!==p.position&&(c.setFromRotationMatrix(a),u.fromArray(p.position),u.applyQuaternion(c),g.position.add(u)),g.updateMatrixWorld(),!1===i.isPresenting)return e;h.near=e.near,l.near=e.near,h.far=e.far,l.far=e.far,f.matrixWorld.copy(e.matrixWorld),f.matrixWorldInverse.copy(e.matrixWorldInverse),h.matrixWorldInverse.fromArray(n.leftViewMatrix),l.matrixWorldInverse.fromArray(n.rightViewMatrix),o.getInverse(a),h.matrixWorldInverse.multiply(o),l.matrixWorldInverse.multiply(o);var m=g.parent;null!==m&&(s.getInverse(m.matrixWorld),h.matrixWorldInverse.multiply(s),l.matrixWorldInverse.multiply(s)),h.matrixWorld.getInverse(h.matrixWorldInverse),l.matrixWorld.getInverse(l.matrixWorldInverse),h.projectionMatrix.fromArray(n.leftProjectionMatrix),l.projectionMatrix.fromArray(n.rightProjectionMatrix),f.projectionMatrix.copy(h.projectionMatrix);var M=i.getLayers();if(M.length){var y=M[0];null!==y.leftBounds&&4===y.leftBounds.length&&h.bounds.fromArray(y.leftBounds),null!==y.rightBounds&&4===y.rightBounds.length&&l.bounds.fromArray(y.rightBounds)}return f},this.getStandingMatrix=function(){return a},this.isPresenting=g;var M=new hi;this.setAnimationLoop=function(e){M.setAnimationLoop(e)},this.submitFrame=function(){g()&&i.submitFrame()},this.dispose=function(){"undefined"!=typeof window&&window.removeEventListener("vrdisplaypresentchange",m)}}function Xr(e){var t=e.context,i=null,n=null,r=null,a=null;function o(){return null!==n&&null!==r}var s=new Vr;s.layers.enable(1),s.viewport=new Xt;var c=new Vr;c.layers.enable(2),c.viewport=new Xt;var u=new Hr([s,c]);function h(e,t){null===t?e.matrixWorld.copy(e.matrix):e.matrixWorld.multiplyMatrices(t.matrixWorld,e.matrix),e.matrixWorldInverse.getInverse(e.matrixWorld)}u.layers.enable(1),u.layers.enable(2),this.enabled=!1,this.getDevice=function(){return i},this.setDevice=function(e){void 0!==e&&(i=e),t.setCompatibleXRDevice(e)},this.setSession=function(i,a){null!==(n=i)&&(n.addEventListener("end",function(){e.setFramebuffer(null),d.stop()}),n.baseLayer=new XRWebGLLayer(n,t),n.requestFrameOfReference(a.frameOfReferenceType).then(function(t){r=t,e.setFramebuffer(n.baseLayer.framebuffer),d.setContext(n),d.start()}))},this.getCamera=function(e){if(o()){var t=e.parent,i=u.cameras;h(u,t);for(var n=0;n=0){var c=n[o];if(void 0!==c){var u=c.normalized,h=c.itemSize,l=A.get(c);if(void 0===l)continue;var f=l.buffer,m=l.type,M=l.bytesPerElement;if(c.isInterleavedBufferAttribute){var y=c.data,v=y.stride,w=c.offset;y&&y.isInstancedInterleavedBuffer?(g.enableAttributeAndDivisor(s,y.meshPerAttribute),void 0===i.maxInstancedCount&&(i.maxInstancedCount=y.meshPerAttribute*y.count)):g.enableAttribute(s),d.bindBuffer(d.ARRAY_BUFFER,f),d.vertexAttribPointer(s,h,m,u,v*M,w*M)}else c.isInstancedBufferAttribute?(g.enableAttributeAndDivisor(s,c.meshPerAttribute),void 0===i.maxInstancedCount&&(i.maxInstancedCount=c.meshPerAttribute*c.count)):g.enableAttribute(s),d.bindBuffer(d.ARRAY_BUFFER,f),d.vertexAttribPointer(s,h,m,u,0,0)}else if(void 0!==a){var x=a[o];if(void 0!==x)switch(x.length){case 2:d.vertexAttrib2fv(s,x);break;case 3:d.vertexAttrib3fv(s,x);break;case 4:d.vertexAttrib4fv(s,x);break;default:d.vertexAttrib1fv(s,x)}}}}g.disableUnusedAttributes()}(n,s,i),null!==l&&d.bindBuffer(d.ELEMENT_ARRAY_BUFFER,h.buffer));var y=1/0;null!==l?y=l.count:void 0!==f&&(y=f.count);var v=i.drawRange.start*m,x=i.drawRange.count*m,E=null!==a?a.start*m:0,T=null!==a?a.count*m:1/0,N=Math.max(v,E),D=Math.min(y,v+x,E+T)-1,b=Math.max(0,D-N+1);if(0!==b){if(r.isMesh)if(!0===n.wireframe)g.setLineWidth(n.wireframeLinewidth*ae()),M.setMode(d.LINES);else switch(r.drawMode){case Et:M.setMode(d.TRIANGLES);break;case Tt:M.setMode(d.TRIANGLE_STRIP);break;case Nt:M.setMode(d.TRIANGLE_FAN)}else if(r.isLine){var _=n.linewidth;void 0===_&&(_=1),g.setLineWidth(_*ae()),r.isLineSegments?M.setMode(d.LINES):r.isLineLoop?M.setMode(d.LINE_LOOP):M.setMode(d.LINE_STRIP)}else r.isPoints&&M.setMode(d.POINTS);i&&i.isInstancedBufferGeometry?i.maxInstancedCount>0&&M.renderInstances(i,N,b):M.render(N,b)}},this.compile=function(e,t){(l=N.get(e,t)).init(),e.traverse(function(e){e.isLight&&(l.pushLight(e),e.castShadow&&l.pushShadow(e))}),l.setupLights(t),e.traverse(function(t){if(t.material)if(Array.isArray(t.material))for(var i=0;i=0&&e.numSupportedMorphTargets++}if(e.morphNormals){e.numSupportedMorphNormals=0;for(p=0;p=0&&e.numSupportedMorphNormals++}var f=n.shader.uniforms;(e.isShaderMaterial||e.isRawShaderMaterial)&&!0!==e.clipping||(n.numClippingPlanes=ee.numPlanes,n.numIntersection=ee.numIntersection,f.clippingPlanes=ee.uniform),n.fog=t,n.lightsHash=r.state.hash,e.lights&&(f.ambientLightColor.value=r.state.ambient,f.directionalLights.value=r.state.directional,f.spotLights.value=r.state.spot,f.rectAreaLights.value=r.state.rectArea,f.pointLights.value=r.state.point,f.hemisphereLights.value=r.state.hemi,f.directionalShadowMap.value=r.state.directionalShadowMap,f.directionalShadowMatrix.value=r.state.directionalShadowMatrix,f.spotShadowMap.value=r.state.spotShadowMap,f.spotShadowMatrix.value=r.state.spotShadowMatrix,f.pointShadowMap.value=r.state.pointShadowMap,f.pointShadowMatrix.value=r.state.pointShadowMatrix);var g=n.program.getUniforms(),m=or.seqWithValue(g.seq,f);n.uniformsList=m}function we(e,t,i,n){H=0;var r=M.get(i),a=l.state.lights;if(te&&(ie||e!==F)){var o=e===F&&i.id===B;ee.setState(i.clippingPlanes,i.clipIntersection,i.clipShadows,e,r,o)}!1===i.needsUpdate&&(void 0===r.program?i.needsUpdate=!0:i.fog&&r.fog!==t?i.needsUpdate=!0:i.lights&&r.lightsHash!==a.state.hash?i.needsUpdate=!0:void 0===r.numClippingPlanes||r.numClippingPlanes===ee.numPlanes&&r.numIntersection===ee.numIntersection||(i.needsUpdate=!0)),i.needsUpdate&&(Ae(i,t,n),i.needsUpdate=!1);var s,c,u=!1,h=!1,p=!1,m=r.program,y=m.getUniforms(),v=r.shader.uniforms;if(g.useProgram(m.program)&&(u=!0,h=!0,p=!0),i.id!==B&&(B=i.id,h=!0),u||e!==F){if(y.setValue(d,"projectionMatrix",e.projectionMatrix),f.logarithmicDepthBuffer&&y.setValue(d,"logDepthBufFC",2/(Math.log(e.far+1)/Math.LN2)),F!==(G||e)&&(F=G||e,h=!0,p=!0),i.isShaderMaterial||i.isMeshPhongMaterial||i.isMeshStandardMaterial||i.envMap){var A=y.map.cameraPosition;void 0!==A&&A.setValue(d,re.setFromMatrixPosition(e.matrixWorld))}(i.isMeshPhongMaterial||i.isMeshLambertMaterial||i.isMeshBasicMaterial||i.isMeshStandardMaterial||i.isShaderMaterial||i.skinning)&&y.setValue(d,"viewMatrix",e.matrixWorldInverse)}if(i.skinning){y.setOptional(d,n,"bindMatrix"),y.setOptional(d,n,"bindMatrixInverse");var w=n.skeleton;if(w){var x=w.bones;if(f.floatVertexTextures){if(void 0===w.boneTexture){var E=Math.sqrt(4*x.length);E=zt.ceilPowerOfTwo(E),E=Math.max(E,4);var T=new Float32Array(E*E*4);T.set(w.boneMatrices);var N=new Jt(T,E,E,We,Be);N.needsUpdate=!0,w.boneMatrices=T,w.boneTexture=N,w.boneTextureSize=E}y.setValue(d,"boneTexture",w.boneTexture),y.setValue(d,"boneTextureSize",w.boneTextureSize)}else y.setOptional(d,w,"boneMatrices")}}return h&&(y.setValue(d,"toneMappingExposure",O.toneMappingExposure),y.setValue(d,"toneMappingWhitePoint",O.toneMappingWhitePoint),i.lights&&(c=p,(s=v).ambientLightColor.needsUpdate=c,s.directionalLights.needsUpdate=c,s.pointLights.needsUpdate=c,s.spotLights.needsUpdate=c,s.rectAreaLights.needsUpdate=c,s.hemisphereLights.needsUpdate=c),t&&i.fog&&function(e,t){e.fogColor.value=t.color,t.isFog?(e.fogNear.value=t.near,e.fogFar.value=t.far):t.isFogExp2&&(e.fogDensity.value=t.density)}(v,t),i.isMeshBasicMaterial?xe(v,i):i.isMeshLambertMaterial?(xe(v,i),function(e,t){t.emissiveMap&&(e.emissiveMap.value=t.emissiveMap)}(v,i)):i.isMeshPhongMaterial?(xe(v,i),i.isMeshToonMaterial?function(e,t){Ee(e,t),t.gradientMap&&(e.gradientMap.value=t.gradientMap)}(v,i):Ee(v,i)):i.isMeshStandardMaterial?(xe(v,i),i.isMeshPhysicalMaterial?function(e,t){e.clearCoat.value=t.clearCoat,e.clearCoatRoughness.value=t.clearCoatRoughness,Te(e,t)}(v,i):Te(v,i)):i.isMeshDepthMaterial?(xe(v,i),function(e,t){t.displacementMap&&(e.displacementMap.value=t.displacementMap,e.displacementScale.value=t.displacementScale,e.displacementBias.value=t.displacementBias)}(v,i)):i.isMeshDistanceMaterial?(xe(v,i),function(e,t){t.displacementMap&&(e.displacementMap.value=t.displacementMap,e.displacementScale.value=t.displacementScale,e.displacementBias.value=t.displacementBias);e.referencePosition.value.copy(t.referencePosition),e.nearDistance.value=t.nearDistance,e.farDistance.value=t.farDistance}(v,i)):i.isMeshNormalMaterial?(xe(v,i),function(e,t){t.bumpMap&&(e.bumpMap.value=t.bumpMap,e.bumpScale.value=t.bumpScale,t.side===b&&(e.bumpScale.value*=-1));t.normalMap&&(e.normalMap.value=t.normalMap,e.normalScale.value.copy(t.normalScale),t.side===b&&e.normalScale.value.negate());t.displacementMap&&(e.displacementMap.value=t.displacementMap,e.displacementScale.value=t.displacementScale,e.displacementBias.value=t.displacementBias)}(v,i)):i.isLineBasicMaterial?(function(e,t){e.diffuse.value=t.color,e.opacity.value=t.opacity}(v,i),i.isLineDashedMaterial&&function(e,t){e.dashSize.value=t.dashSize,e.totalSize.value=t.dashSize+t.gapSize,e.scale.value=t.scale}(v,i)):i.isPointsMaterial?function(e,t){e.diffuse.value=t.color,e.opacity.value=t.opacity,e.size.value=t.size*Z,e.scale.value=.5*X,e.map.value=t.map,null!==t.map&&(!0===t.map.matrixAutoUpdate&&t.map.updateMatrix(),e.uvTransform.value.copy(t.map.matrix))}(v,i):i.isShadowMaterial&&(v.color.value=i.color,v.opacity.value=i.opacity),void 0!==v.ltc_1&&(v.ltc_1.value=ci.LTC_1),void 0!==v.ltc_2&&(v.ltc_2.value=ci.LTC_2),or.upload(d,r.uniformsList,v,O)),i.isShaderMaterial&&!0===i.uniformsNeedUpdate&&(or.upload(d,r.uniformsList,v,O),i.uniformsNeedUpdate=!1),y.setValue(d,"modelViewMatrix",n.modelViewMatrix),y.setValue(d,"normalMatrix",n.normalMatrix),y.setValue(d,"modelMatrix",n.matrixWorld),m}function xe(e,t){var i;e.opacity.value=t.opacity,t.color&&(e.diffuse.value=t.color),t.emissive&&e.emissive.value.copy(t.emissive).multiplyScalar(t.emissiveIntensity),t.map&&(e.map.value=t.map),t.alphaMap&&(e.alphaMap.value=t.alphaMap),t.specularMap&&(e.specularMap.value=t.specularMap),t.envMap&&(e.envMap.value=t.envMap,e.flipEnvMap.value=t.envMap&&t.envMap.isCubeTexture?-1:1,e.reflectivity.value=t.reflectivity,e.refractionRatio.value=t.refractionRatio,e.maxMipLevel.value=M.get(t.envMap).__maxMipLevel),t.lightMap&&(e.lightMap.value=t.lightMap,e.lightMapIntensity.value=t.lightMapIntensity),t.aoMap&&(e.aoMap.value=t.aoMap,e.aoMapIntensity.value=t.aoMapIntensity),t.map?i=t.map:t.specularMap?i=t.specularMap:t.displacementMap?i=t.displacementMap:t.normalMap?i=t.normalMap:t.bumpMap?i=t.bumpMap:t.roughnessMap?i=t.roughnessMap:t.metalnessMap?i=t.metalnessMap:t.alphaMap?i=t.alphaMap:t.emissiveMap&&(i=t.emissiveMap),void 0!==i&&(i.isWebGLRenderTarget&&(i=i.texture),!0===i.matrixAutoUpdate&&i.updateMatrix(),e.uvTransform.value.copy(i.matrix))}function Ee(e,t){e.specular.value=t.specular,e.shininess.value=Math.max(t.shininess,1e-4),t.emissiveMap&&(e.emissiveMap.value=t.emissiveMap),t.bumpMap&&(e.bumpMap.value=t.bumpMap,e.bumpScale.value=t.bumpScale,t.side===b&&(e.bumpScale.value*=-1)),t.normalMap&&(e.normalMap.value=t.normalMap,e.normalScale.value.copy(t.normalScale),t.side===b&&e.normalScale.value.negate()),t.displacementMap&&(e.displacementMap.value=t.displacementMap,e.displacementScale.value=t.displacementScale,e.displacementBias.value=t.displacementBias)}function Te(e,t){e.roughness.value=t.roughness,e.metalness.value=t.metalness,t.roughnessMap&&(e.roughnessMap.value=t.roughnessMap),t.metalnessMap&&(e.metalnessMap.value=t.metalnessMap),t.emissiveMap&&(e.emissiveMap.value=t.emissiveMap),t.bumpMap&&(e.bumpMap.value=t.bumpMap,e.bumpScale.value=t.bumpScale,t.side===b&&(e.bumpScale.value*=-1)),t.normalMap&&(e.normalMap.value=t.normalMap,e.normalScale.value.copy(t.normalScale),t.side===b&&e.normalScale.value.negate()),t.displacementMap&&(e.displacementMap.value=t.displacementMap,e.displacementScale.value=t.displacementScale,e.displacementBias.value=t.displacementBias),t.envMap&&(e.envMapIntensity.value=t.envMapIntensity)}Me.setAnimationLoop(function(){ce.isPresenting()||ge&&ge()}),Me.setContext(window),this.setAnimationLoop=function(e){ge=e,ce.setAnimationLoop(e),Me.start()},this.render=function(e,t,i,n){if(t&&t.isCamera){if(!z){k="",B=-1,F=null,!0===e.autoUpdate&&e.updateMatrixWorld(),null===t.parent&&t.updateMatrixWorld(),ce.enabled&&(t=ce.getCamera(t)),(l=N.get(e,t)).init(),e.onBeforeRender(O,e,t,i),ne.multiplyMatrices(t.projectionMatrix,t.matrixWorldInverse),$.setFromMatrix(ne),ie=this.localClippingEnabled,te=ee.init(this.clippingPlanes,ie,t),(h=T.get(e,t)).init(),function e(t,i,n){if(!1===t.visible)return;var r=t.layers.test(i.layers);if(r)if(t.isLight)l.pushLight(t),t.castShadow&&l.pushShadow(t);else if(t.isSprite)t.frustumCulled&&!$.intersectsSprite(t)||l.pushSprite(t);else if(t.isImmediateRenderObject)n&&re.setFromMatrixPosition(t.matrixWorld).applyMatrix4(ne),h.push(t,null,t.material,re.z,null);else if((t.isMesh||t.isLine||t.isPoints)&&(t.isSkinnedMesh&&t.skeleton.update(),!t.frustumCulled||$.intersectsObject(t))){n&&re.setFromMatrixPosition(t.matrixWorld).applyMatrix4(ne);var a=x.update(t),o=t.material;if(Array.isArray(o))for(var s=a.groups,c=0,u=s.length;c=f.maxTextures&&console.warn("THREE.WebGLRenderer: Trying to use "+e+" texture units while this GPU supports only "+f.maxTextures),H+=1,e},this.setTexture2D=(me=!1,function(e,t){e&&e.isWebGLRenderTarget&&(me||(console.warn("THREE.WebGLRenderer.setTexture2D: don't use render targets as textures. Use their .texture property instead."),me=!0),e=e.texture),v.setTexture2D(e,t)}),this.setTexture=function(){var e=!1;return function(t,i){e||(console.warn("THREE.WebGLRenderer: .setTexture is deprecated, use setTexture2D instead."),e=!0),v.setTexture2D(t,i)}}(),this.setTextureCube=function(){var e=!1;return function(t,i){t&&t.isWebGLRenderTargetCube&&(e||(console.warn("THREE.WebGLRenderer.setTextureCube: don't use cube render targets as textures. Use their .texture property instead."),e=!0),t=t.texture),t&&t.isCubeTexture||Array.isArray(t.image)&&6===t.image.length?v.setTextureCube(t,i):v.setTextureCubeDynamic(t,i)}}(),this.setFramebuffer=function(e){R=e},this.getRenderTarget=function(){return U},this.setRenderTarget=function(e){U=e,e&&void 0===M.get(e).__webglFramebuffer&&v.setupRenderTarget(e);var t=R,i=!1;if(e){var n=M.get(e).__webglFramebuffer;e.isWebGLRenderTargetCube?(t=n[e.activeCubeFace],i=!0):t=n,Q.copy(e.viewport),Y.copy(e.scissor),V=e.scissorTest}else Q.copy(q).multiplyScalar(Z),Y.copy(J).multiplyScalar(Z),V=K;if(P!==t&&(d.bindFramebuffer(d.FRAMEBUFFER,t),P=t),g.viewport(Q),g.scissor(Y),g.setScissorTest(V),i){var r=M.get(e.texture);d.framebufferTexture2D(d.FRAMEBUFFER,d.COLOR_ATTACHMENT0,d.TEXTURE_CUBE_MAP_POSITIVE_X+e.activeCubeFace,r.__webglTexture,e.activeMipMapLevel)}},this.readRenderTargetPixels=function(e,t,i,n,r,a){if(e&&e.isWebGLRenderTarget){var o=M.get(e).__webglFramebuffer;if(o){var s=!1;o!==P&&(d.bindFramebuffer(d.FRAMEBUFFER,o),s=!0);try{var c=e.texture,u=c.format,h=c.type;if(u!==We&&C.convert(u)!==d.getParameter(d.IMPLEMENTATION_COLOR_READ_FORMAT))return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in RGBA or implementation defined format.");if(!(h===Ce||C.convert(h)===d.getParameter(d.IMPLEMENTATION_COLOR_READ_TYPE)||h===Be&&(p.get("OES_texture_float")||p.get("WEBGL_color_buffer_float"))||h===ke&&p.get("EXT_color_buffer_half_float")))return void console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not in UnsignedByteType or implementation defined type.");d.checkFramebufferStatus(d.FRAMEBUFFER)===d.FRAMEBUFFER_COMPLETE?t>=0&&t<=e.width-n&&i>=0&&i<=e.height-r&&d.readPixels(t,i,n,r,C.convert(u),C.convert(h),a):console.error("THREE.WebGLRenderer.readRenderTargetPixels: readPixels from renderTarget failed. Framebuffer not complete.")}finally{s&&d.bindFramebuffer(d.FRAMEBUFFER,P)}}}else console.error("THREE.WebGLRenderer.readRenderTargetPixels: renderTarget is not THREE.WebGLRenderTarget.")},this.copyFramebufferToTexture=function(e,t,i){var n=t.image.width,r=t.image.height,a=C.convert(t.format);this.setTexture2D(t,0),d.copyTexImage2D(d.TEXTURE_2D,i||0,a,e.x,e.y,n,r,0)},this.copyTextureToTexture=function(e,t,i,n){var r=t.image.width,a=t.image.height,o=C.convert(i.format),s=C.convert(i.type);this.setTexture2D(i,0),t.isDataTexture?d.texSubImage2D(d.TEXTURE_2D,n||0,e.x,e.y,r,a,o,s,t.image.data):d.texSubImage2D(d.TEXTURE_2D,n||0,e.x,e.y,o,s,t.image)}}function qr(e,t){this.name="",this.color=new oi(e),this.density=void 0!==t?t:25e-5}function Jr(e,t,i){this.name="",this.color=new oi(e),this.near=void 0!==t?t:1,this.far=void 0!==i?i:1e3}function Kr(){vi.call(this),this.type="Scene",this.background=null,this.fog=null,this.overrideMaterial=null,this.autoUpdate=!0}function $r(e){$i.call(this),this.type="SpriteMaterial",this.color=new oi(16777215),this.map=null,this.rotation=0,this.fog=!1,this.lights=!1,this.setValues(e)}function ea(e){vi.call(this),this.type="Sprite",this.material=void 0!==e?e:new $r,this.center=new Rt(.5,.5)}function ta(){vi.call(this),this.type="LOD",Object.defineProperties(this,{levels:{enumerable:!0,value:[]}})}function ia(e,t){if(e=e||[],this.bones=e.slice(0),this.boneMatrices=new Float32Array(16*this.bones.length),void 0===t)this.calculateInverses();else if(this.bones.length===t.length)this.boneInverses=t.slice(0);else{console.warn("THREE.Skeleton boneInverses is the wrong length."),this.boneInverses=[];for(var i=0,n=this.bones.length;i=0?(e(m-1e-5,g,l),d.subVectors(h,l)):(e(m+1e-5,g,l),d.subVectors(l,h)),g-1e-5>=0?(e(m,g-1e-5,l),p.subVectors(h,l)):(e(m,g+1e-5,l),p.subVectors(l,h)),u.crossVectors(d,p).normalize(),s.push(u.x,u.y,u.z),c.push(m,g)}}for(n=0;n.9&&o<.1&&(t<.2&&(a[e+0]+=1),i<.2&&(a[e+2]+=1),n<.2&&(a[e+4]+=1))}}()}(),this.addAttribute("position",new zi(r,3)),this.addAttribute("normal",new zi(r.slice(),3)),this.addAttribute("uv",new zi(a,2)),0===n?this.computeVertexNormals():this.normalizeNormals()}function Aa(e,t){Di.call(this),this.type="TetrahedronGeometry",this.parameters={radius:e,detail:t},this.fromBufferGeometry(new wa(e,t)),this.mergeVertices()}function wa(e,t){va.call(this,[1,1,1,-1,-1,1,-1,1,-1,1,-1,-1],[2,1,0,0,3,2,1,3,0,2,3,1],e,t),this.type="TetrahedronBufferGeometry",this.parameters={radius:e,detail:t}}function xa(e,t){Di.call(this),this.type="OctahedronGeometry",this.parameters={radius:e,detail:t},this.fromBufferGeometry(new Ea(e,t)),this.mergeVertices()}function Ea(e,t){va.call(this,[1,0,0,-1,0,0,0,1,0,0,-1,0,0,0,1,0,0,-1],[0,2,4,0,4,3,0,3,5,0,5,2,1,2,5,1,5,3,1,3,4,1,4,2],e,t),this.type="OctahedronBufferGeometry",this.parameters={radius:e,detail:t}}function Ta(e,t){Di.call(this),this.type="IcosahedronGeometry",this.parameters={radius:e,detail:t},this.fromBufferGeometry(new Na(e,t)),this.mergeVertices()}function Na(e,t){var i=(1+Math.sqrt(5))/2,n=[-1,i,0,1,i,0,-1,-i,0,1,-i,0,0,-1,i,0,1,i,0,-1,-i,0,1,-i,i,0,-1,i,0,1,-i,0,-1,-i,0,1];va.call(this,n,[0,11,5,0,5,1,0,1,7,0,7,10,0,10,11,1,5,9,5,11,4,11,10,2,10,7,6,7,1,8,3,9,4,3,4,2,3,2,6,3,6,8,3,8,9,4,9,5,2,4,11,6,2,10,8,6,7,9,8,1],e,t),this.type="IcosahedronBufferGeometry",this.parameters={radius:e,detail:t}}function Da(e,t){Di.call(this),this.type="DodecahedronGeometry",this.parameters={radius:e,detail:t},this.fromBufferGeometry(new La(e,t)),this.mergeVertices()}function La(e,t){var i=(1+Math.sqrt(5))/2,n=1/i,r=[-1,-1,-1,-1,-1,1,-1,1,-1,-1,1,1,1,-1,-1,1,-1,1,1,1,-1,1,1,1,0,-n,-i,0,-n,i,0,n,-i,0,n,i,-n,-i,0,-n,i,0,n,-i,0,n,i,0,-i,0,-n,i,0,-n,-i,0,n,i,0,n];va.call(this,r,[3,11,7,3,7,15,3,15,13,7,19,17,7,17,6,7,6,15,17,4,8,17,8,10,17,10,6,8,0,16,8,16,2,8,2,10,0,12,1,0,1,18,0,18,16,6,10,2,6,2,13,6,13,15,2,16,18,2,18,3,2,3,13,18,1,9,18,9,11,18,11,3,4,14,12,4,12,0,4,0,8,11,9,5,11,5,19,11,19,7,19,5,14,19,14,4,19,4,17,1,12,14,1,14,5,1,5,9],e,t),this.type="DodecahedronBufferGeometry",this.parameters={radius:e,detail:t}}function ba(e,t,i,n,r,a){Di.call(this),this.type="TubeGeometry",this.parameters={path:e,tubularSegments:t,radius:i,radialSegments:n,closed:r},void 0!==a&&console.warn("THREE.TubeGeometry: taper has been removed.");var o=new Ia(e,t,i,n,r);this.tangents=o.tangents,this.normals=o.normals,this.binormals=o.binormals,this.fromBufferGeometry(o),this.mergeVertices()}function Ia(e,t,i,n,r){ki.call(this),this.type="TubeBufferGeometry",this.parameters={path:e,tubularSegments:t,radius:i,radialSegments:n,closed:r},t=t||64,i=i||1,n=n||8,r=r||!1;var a=e.computeFrenetFrames(t,r);this.tangents=a.tangents,this.normals=a.normals,this.binormals=a.binormals;var o,s,c=new Bt,u=new Bt,h=new Rt,l=new Bt,d=[],p=[],f=[],g=[];function m(r){l=e.getPointAt(r/t,l);var o=a.normals[r],h=a.binormals[r];for(s=0;s<=n;s++){var f=s/n*Math.PI*2,g=Math.sin(f),m=-Math.cos(f);u.x=m*o.x+g*h.x,u.y=m*o.y+g*h.y,u.z=m*o.z+g*h.z,u.normalize(),p.push(u.x,u.y,u.z),c.x=l.x+i*u.x,c.y=l.y+i*u.y,c.z=l.z+i*u.z,d.push(c.x,c.y,c.z)}}!function(){for(o=0;oi)){var n=e.ray.origin.distanceTo(Nr);ne.far||t.push({distance:n,point:Nr.clone(),face:null,object:this})}}),clone:function(){return new this.constructor(this.material).copy(this)},copy:function(e){return vi.prototype.copy.call(this,e),void 0!==e.center&&this.center.copy(e.center),this}}),ta.prototype=Object.assign(Object.create(vi.prototype),{constructor:ta,copy:function(e){vi.prototype.copy.call(this,e,!1);for(var t=e.levels,i=0,n=t.length;i1){e.setFromMatrixPosition(i.matrixWorld),t.setFromMatrixPosition(this.matrixWorld);var r=e.distanceTo(t);n[0].object.visible=!0;for(var a=1,o=n.length;a=n[a].distance;a++)n[a-1].object.visible=!1,n[a].object.visible=!0;for(;ao))d.applyMatrix4(this.matrixWorld),(E=n.ray.origin.distanceTo(d))n.far||r.push({distance:E,point:l.clone().applyMatrix4(this.matrixWorld),index:M,face:null,faceIndex:null,object:this})}else for(M=0,y=g.length/3-1;Mo))d.applyMatrix4(this.matrixWorld),(E=n.ray.origin.distanceTo(d))n.far||r.push({distance:E,point:l.clone().applyMatrix4(this.matrixWorld),index:M,face:null,faceIndex:null,object:this})}}else if(s.isGeometry){var w=s.vertices,x=w.length;for(M=0;Mo))d.applyMatrix4(this.matrixWorld),(E=n.ray.origin.distanceTo(d))n.far||r.push({distance:E,point:l.clone().applyMatrix4(this.matrixWorld),index:M,face:null,faceIndex:null,object:this})}}}}}(),clone:function(){return new this.constructor(this.geometry,this.material).copy(this)}}),sa.prototype=Object.assign(Object.create(oa.prototype),{constructor:sa,isLineSegments:!0,computeLineDistances:function(){var e=new Bt,t=new Bt;return function(){var i=this.geometry;if(i.isBufferGeometry)if(null===i.index){for(var n=i.attributes.position,r=[],a=0,o=n.count;an.far)return;r.push({distance:c,distanceToRay:Math.sqrt(o),point:d.clone(),index:i,face:null,object:a})}}}}(),clone:function(){return new this.constructor(this.geometry,this.material).copy(this)}}),la.prototype=Object.assign(Object.create(vi.prototype),{constructor:la,isGroup:!0}),da.prototype=Object.assign(Object.create(Wt.prototype),{constructor:da,isVideoTexture:!0,update:function(){var e=this.image;e.readyState>=e.HAVE_CURRENT_DATA&&(this.needsUpdate=!0)}}),pa.prototype=Object.create(Wt.prototype),pa.prototype.constructor=pa,pa.prototype.isCompressedTexture=!0,fa.prototype=Object.create(Wt.prototype),fa.prototype.constructor=fa,fa.prototype.isDepthTexture=!0,ga.prototype=Object.create(ki.prototype),ga.prototype.constructor=ga,ma.prototype=Object.create(Di.prototype),ma.prototype.constructor=ma,Ma.prototype=Object.create(ki.prototype),Ma.prototype.constructor=Ma,ya.prototype=Object.create(Di.prototype),ya.prototype.constructor=ya,va.prototype=Object.create(ki.prototype),va.prototype.constructor=va,Aa.prototype=Object.create(Di.prototype),Aa.prototype.constructor=Aa,wa.prototype=Object.create(va.prototype),wa.prototype.constructor=wa,xa.prototype=Object.create(Di.prototype),xa.prototype.constructor=xa,Ea.prototype=Object.create(va.prototype),Ea.prototype.constructor=Ea,Ta.prototype=Object.create(Di.prototype),Ta.prototype.constructor=Ta,Na.prototype=Object.create(va.prototype),Na.prototype.constructor=Na,Da.prototype=Object.create(Di.prototype),Da.prototype.constructor=Da,La.prototype=Object.create(va.prototype),La.prototype.constructor=La,ba.prototype=Object.create(Di.prototype),ba.prototype.constructor=ba,Ia.prototype=Object.create(ki.prototype),Ia.prototype.constructor=Ia,_a.prototype=Object.create(Di.prototype),_a.prototype.constructor=_a,Sa.prototype=Object.create(ki.prototype),Sa.prototype.constructor=Sa,ja.prototype=Object.create(Di.prototype),ja.prototype.constructor=ja,Ca.prototype=Object.create(ki.prototype),Ca.prototype.constructor=Ca;var Oa=function(e,t,i){i=i||2;var n,r,a,o,s,c,u,h=t&&t.length,l=h?t[0]*i:e.length,d=za(e,0,l,i,!0),p=[];if(!d)return p;if(h&&(d=function(e,t,i,n){var r,a,o,s,c,u=[];for(r=0,a=t.length;r80*i){n=a=e[0],r=o=e[1];for(var f=i;fa&&(a=s),c>o&&(o=c);u=0!==(u=Math.max(a-n,o-r))?1/u:0}return Ua(d,p,i,n,r,u),p};function za(e,t,i,n,r){var a,o;if(r===function(e,t,i,n){for(var r=0,a=t,o=i-n;a0)for(a=t;a=t;a-=n)o=$a(a,e[a],e[a+1],o);return o&&Za(o,o.next)&&(eo(o),o=o.next),o}function Ra(e,t){if(!e)return e;t||(t=e);var i,n=e;do{if(i=!1,n.steiner||!Za(n,n.next)&&0!==Xa(n.prev,n,n.next))n=n.next;else{if(eo(n),(n=t=n.prev)===n.next)break;i=!0}}while(i||n!==t);return t}function Ua(e,t,i,n,r,a,o){if(e){!o&&a&&function(e,t,i,n){var r=e;do{null===r.z&&(r.z=Ya(r.x,r.y,t,i,n)),r.prevZ=r.prev,r.nextZ=r.next,r=r.next}while(r!==e);r.prevZ.nextZ=null,r.prevZ=null,function(e){var t,i,n,r,a,o,s,c,u=1;do{for(i=e,e=null,a=null,o=0;i;){for(o++,n=i,s=0,t=0;t0||c>0&&n;)0!==s&&(0===c||!n||i.z<=n.z)?(r=i,i=i.nextZ,s--):(r=n,n=n.nextZ,c--),a?a.nextZ=r:e=r,r.prevZ=a,a=r;i=n}a.nextZ=null,u*=2}while(o>1)}(r)}(e,n,r,a);for(var s,c,u=e;e.prev!==e.next;)if(s=e.prev,c=e.next,a?Ba(e,n,r,a):Pa(e))t.push(s.i/i),t.push(e.i/i),t.push(c.i/i),eo(e),e=c.next,u=c.next;else if((e=c)===u){o?1===o?Ua(e=ka(e,t,i),t,i,n,r,a,2):2===o&&Fa(e,t,i,n,r,a):Ua(Ra(e),t,i,n,r,a,1);break}}}function Pa(e){var t=e.prev,i=e,n=e.next;if(Xa(t,i,n)>=0)return!1;for(var r=e.next.next;r!==e.prev;){if(Ha(t.x,t.y,i.x,i.y,n.x,n.y,r.x,r.y)&&Xa(r.prev,r,r.next)>=0)return!1;r=r.next}return!0}function Ba(e,t,i,n){var r=e.prev,a=e,o=e.next;if(Xa(r,a,o)>=0)return!1;for(var s=r.xa.x?r.x>o.x?r.x:o.x:a.x>o.x?a.x:o.x,h=r.y>a.y?r.y>o.y?r.y:o.y:a.y>o.y?a.y:o.y,l=Ya(s,c,t,i,n),d=Ya(u,h,t,i,n),p=e.nextZ;p&&p.z<=d;){if(p!==e.prev&&p!==e.next&&Ha(r.x,r.y,a.x,a.y,o.x,o.y,p.x,p.y)&&Xa(p.prev,p,p.next)>=0)return!1;p=p.nextZ}for(p=e.prevZ;p&&p.z>=l;){if(p!==e.prev&&p!==e.next&&Ha(r.x,r.y,a.x,a.y,o.x,o.y,p.x,p.y)&&Xa(p.prev,p,p.next)>=0)return!1;p=p.prevZ}return!0}function ka(e,t,i){var n=e;do{var r=n.prev,a=n.next.next;!Za(r,a)&&qa(r,n,n.next,a)&&Ja(r,a)&&Ja(a,r)&&(t.push(r.i/i),t.push(n.i/i),t.push(a.i/i),eo(n),eo(n.next),n=e=a),n=n.next}while(n!==e);return n}function Fa(e,t,i,n,r,a){var o=e;do{for(var s=o.next.next;s!==o.prev;){if(o.i!==s.i&&Wa(o,s)){var c=Ka(o,s);return o=Ra(o,o.next),c=Ra(c,c.next),Ua(o,t,i,n,r,a),void Ua(c,t,i,n,r,a)}s=s.next}o=o.next}while(o!==e)}function Ga(e,t){return e.x-t.x}function Qa(e,t){if(t=function(e,t){var i,n=t,r=e.x,a=e.y,o=-1/0;do{if(a<=n.y&&a>=n.next.y&&n.next.y!==n.y){var s=n.x+(a-n.y)*(n.next.x-n.x)/(n.next.y-n.y);if(s<=r&&s>o){if(o=s,s===r){if(a===n.y)return n;if(a===n.next.y)return n.next}i=n.x=n.x&&n.x>=h&&r!==n.x&&Ha(ai.x)&&Ja(n,e)&&(i=n,d=c),n=n.next;return i}(e,t)){var i=Ka(t,e);Ra(i,i.next)}}function Ya(e,t,i,n,r){return(e=1431655765&((e=858993459&((e=252645135&((e=16711935&((e=32767*(e-i)*r)|e<<8))|e<<4))|e<<2))|e<<1))|(t=1431655765&((t=858993459&((t=252645135&((t=16711935&((t=32767*(t-n)*r)|t<<8))|t<<4))|t<<2))|t<<1))<<1}function Va(e){var t=e,i=e;do{t.x=0&&(e-o)*(n-s)-(i-o)*(t-s)>=0&&(i-o)*(a-s)-(r-o)*(n-s)>=0}function Wa(e,t){return e.next.i!==t.i&&e.prev.i!==t.i&&!function(e,t){var i=e;do{if(i.i!==e.i&&i.next.i!==e.i&&i.i!==t.i&&i.next.i!==t.i&&qa(i,i.next,e,t))return!0;i=i.next}while(i!==e);return!1}(e,t)&&Ja(e,t)&&Ja(t,e)&&function(e,t){var i=e,n=!1,r=(e.x+t.x)/2,a=(e.y+t.y)/2;do{i.y>a!=i.next.y>a&&i.next.y!==i.y&&r<(i.next.x-i.x)*(a-i.y)/(i.next.y-i.y)+i.x&&(n=!n),i=i.next}while(i!==e);return n}(e,t)}function Xa(e,t,i){return(t.y-e.y)*(i.x-t.x)-(t.x-e.x)*(i.y-t.y)}function Za(e,t){return e.x===t.x&&e.y===t.y}function qa(e,t,i,n){return!!(Za(e,t)&&Za(i,n)||Za(e,n)&&Za(i,t))||Xa(e,t,i)>0!=Xa(e,t,n)>0&&Xa(i,n,e)>0!=Xa(i,n,t)>0}function Ja(e,t){return Xa(e.prev,e,e.next)<0?Xa(e,t,e.next)>=0&&Xa(e,e.prev,t)>=0:Xa(e,t,e.prev)<0||Xa(e,e.next,t)<0}function Ka(e,t){var i=new to(e.i,e.x,e.y),n=new to(t.i,t.x,t.y),r=e.next,a=t.prev;return e.next=t,t.prev=e,i.next=r,r.prev=i,n.next=i,i.prev=n,a.next=n,n.prev=a,n}function $a(e,t,i,n){var r=new to(e,t,i);return n?(r.next=n.next,r.prev=n,n.next.prev=r,n.next=r):(r.prev=r,r.next=r),r}function eo(e){e.next.prev=e.prev,e.prev.next=e.next,e.prevZ&&(e.prevZ.nextZ=e.nextZ),e.nextZ&&(e.nextZ.prevZ=e.prevZ)}function to(e,t,i){this.i=e,this.x=t,this.y=i,this.prev=null,this.next=null,this.z=null,this.prevZ=null,this.nextZ=null,this.steiner=!1}var io={area:function(e){for(var t=e.length,i=0,n=t-1,r=0;r2&&e[t-1].equals(e[0])&&e.pop()}function ro(e,t){for(var i=0;iNumber.EPSILON){var d=Math.sqrt(h),p=Math.sqrt(c*c+u*u),f=t.x-s/d,g=t.y+o/d,m=((i.x-u/p-f)*u-(i.y+c/p-g)*c)/(o*u-s*c),M=(n=f+o*m-e.x)*n+(r=g+s*m-e.y)*r;if(M<=2)return new Rt(n,r);a=Math.sqrt(M/2)}else{var y=!1;o>Number.EPSILON?c>Number.EPSILON&&(y=!0):o<-Number.EPSILON?c<-Number.EPSILON&&(y=!0):Math.sign(s)===Math.sign(u)&&(y=!0),y?(n=-s,r=o,a=Math.sqrt(h)):(n=o,r=s,a=Math.sqrt(h/2))}return new Rt(n/a,r/a)}for(var B=[],k=0,F=b.length,G=F-1,Q=k+1;k=0;_--){for(j=_/d,C=h*Math.cos(j*Math.PI/2),S=l*Math.sin(j*Math.PI/2),k=0,F=b.length;k=0;){i=k,(n=k-1)<0&&(n=e.length-1);var r=0,a=s+2*d;for(r=0;r0)&&f.push(w,x,T),(c!==i-1||u0&&M(!0),t>0&&M(!1)),this.setIndex(u),this.addAttribute("position",new zi(h,3)),this.addAttribute("normal",new zi(l,3)),this.addAttribute("uv",new zi(d,2))}function To(e,t,i,n,r,a,o){xo.call(this,0,e,t,i,n,r,a,o),this.type="ConeGeometry",this.parameters={radius:e,height:t,radialSegments:i,heightSegments:n,openEnded:r,thetaStart:a,thetaLength:o}}function No(e,t,i,n,r,a,o){Eo.call(this,0,e,t,i,n,r,a,o),this.type="ConeBufferGeometry",this.parameters={radius:e,height:t,radialSegments:i,heightSegments:n,openEnded:r,thetaStart:a,thetaLength:o}}function Do(e,t,i,n){Di.call(this),this.type="CircleGeometry",this.parameters={radius:e,segments:t,thetaStart:i,thetaLength:n},this.fromBufferGeometry(new Lo(e,t,i,n)),this.mergeVertices()}function Lo(e,t,i,n){ki.call(this),this.type="CircleBufferGeometry",this.parameters={radius:e,segments:t,thetaStart:i,thetaLength:n},e=e||1,t=void 0!==t?Math.max(3,t):8,i=void 0!==i?i:0,n=void 0!==n?n:2*Math.PI;var r,a,o=[],s=[],c=[],u=[],h=new Bt,l=new Rt;for(s.push(0,0,0),c.push(0,0,1),u.push(.5,.5),a=0,r=3;a<=t;a++,r+=3){var d=i+a/t*n;h.x=e*Math.cos(d),h.y=e*Math.sin(d),s.push(h.x,h.y,h.z),c.push(0,0,1),l.x=(s[r]/e+1)/2,l.y=(s[r+1]/e+1)/2,u.push(l.x,l.y)}for(r=1;r<=t;r++)o.push(r,r+1,0);this.setIndex(o),this.addAttribute("position",new zi(s,3)),this.addAttribute("normal",new zi(c,3)),this.addAttribute("uv",new zi(u,2))}uo.prototype=Object.create(Di.prototype),uo.prototype.constructor=uo,ho.prototype=Object.create(oo.prototype),ho.prototype.constructor=ho,lo.prototype=Object.create(Di.prototype),lo.prototype.constructor=lo,po.prototype=Object.create(ki.prototype),po.prototype.constructor=po,fo.prototype=Object.create(Di.prototype),fo.prototype.constructor=fo,go.prototype=Object.create(ki.prototype),go.prototype.constructor=go,mo.prototype=Object.create(Di.prototype),mo.prototype.constructor=mo,Mo.prototype=Object.create(ki.prototype),Mo.prototype.constructor=Mo,yo.prototype=Object.create(Di.prototype),yo.prototype.constructor=yo,yo.prototype.toJSON=function(){var e=Di.prototype.toJSON.call(this);return Ao(this.parameters.shapes,e)},vo.prototype=Object.create(ki.prototype),vo.prototype.constructor=vo,vo.prototype.toJSON=function(){var e=ki.prototype.toJSON.call(this);return Ao(this.parameters.shapes,e)},wo.prototype=Object.create(ki.prototype),wo.prototype.constructor=wo,xo.prototype=Object.create(Di.prototype),xo.prototype.constructor=xo,Eo.prototype=Object.create(ki.prototype),Eo.prototype.constructor=Eo,To.prototype=Object.create(xo.prototype),To.prototype.constructor=To,No.prototype=Object.create(Eo.prototype),No.prototype.constructor=No,Do.prototype=Object.create(Di.prototype),Do.prototype.constructor=Do,Lo.prototype=Object.create(ki.prototype),Lo.prototype.constructor=Lo;var bo=Object.freeze({WireframeGeometry:ga,ParametricGeometry:ma,ParametricBufferGeometry:Ma,TetrahedronGeometry:Aa,TetrahedronBufferGeometry:wa,OctahedronGeometry:xa,OctahedronBufferGeometry:Ea,IcosahedronGeometry:Ta,IcosahedronBufferGeometry:Na,DodecahedronGeometry:Da,DodecahedronBufferGeometry:La,PolyhedronGeometry:ya,PolyhedronBufferGeometry:va,TubeGeometry:ba,TubeBufferGeometry:Ia,TorusKnotGeometry:_a,TorusKnotBufferGeometry:Sa,TorusGeometry:ja,TorusBufferGeometry:Ca,TextGeometry:uo,TextBufferGeometry:ho,SphereGeometry:lo,SphereBufferGeometry:po,RingGeometry:fo,RingBufferGeometry:go,PlaneGeometry:Qi,PlaneBufferGeometry:Yi,LatheGeometry:mo,LatheBufferGeometry:Mo,ShapeGeometry:yo,ShapeBufferGeometry:vo,ExtrudeGeometry:ao,ExtrudeBufferGeometry:oo,EdgesGeometry:wo,ConeGeometry:To,ConeBufferGeometry:No,CylinderGeometry:xo,CylinderBufferGeometry:Eo,CircleGeometry:Do,CircleBufferGeometry:Lo,BoxGeometry:Fi,BoxBufferGeometry:Gi});function Io(e){$i.call(this),this.type="ShadowMaterial",this.color=new oi(0),this.transparent=!0,this.setValues(e)}function _o(e){tn.call(this,e),this.type="RawShaderMaterial"}function So(e){$i.call(this),this.defines={STANDARD:""},this.type="MeshStandardMaterial",this.color=new oi(16777215),this.roughness=.5,this.metalness=.5,this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.emissive=new oi(0),this.emissiveIntensity=1,this.emissiveMap=null,this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalScale=new Rt(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.roughnessMap=null,this.metalnessMap=null,this.alphaMap=null,this.envMap=null,this.envMapIntensity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap="round",this.wireframeLinejoin="round",this.skinning=!1,this.morphTargets=!1,this.morphNormals=!1,this.setValues(e)}function jo(e){So.call(this),this.defines={PHYSICAL:""},this.type="MeshPhysicalMaterial",this.reflectivity=.5,this.clearCoat=0,this.clearCoatRoughness=0,this.setValues(e)}function Co(e){$i.call(this),this.type="MeshPhongMaterial",this.color=new oi(16777215),this.specular=new oi(1118481),this.shininess=30,this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.emissive=new oi(0),this.emissiveIntensity=1,this.emissiveMap=null,this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalScale=new Rt(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.combine=ue,this.reflectivity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap="round",this.wireframeLinejoin="round",this.skinning=!1,this.morphTargets=!1,this.morphNormals=!1,this.setValues(e)}function Oo(e){Co.call(this),this.defines={TOON:""},this.type="MeshToonMaterial",this.gradientMap=null,this.setValues(e)}function zo(e){$i.call(this),this.type="MeshNormalMaterial",this.bumpMap=null,this.bumpScale=1,this.normalMap=null,this.normalScale=new Rt(1,1),this.displacementMap=null,this.displacementScale=1,this.displacementBias=0,this.wireframe=!1,this.wireframeLinewidth=1,this.fog=!1,this.lights=!1,this.skinning=!1,this.morphTargets=!1,this.morphNormals=!1,this.setValues(e)}function Ro(e){$i.call(this),this.type="MeshLambertMaterial",this.color=new oi(16777215),this.map=null,this.lightMap=null,this.lightMapIntensity=1,this.aoMap=null,this.aoMapIntensity=1,this.emissive=new oi(0),this.emissiveIntensity=1,this.emissiveMap=null,this.specularMap=null,this.alphaMap=null,this.envMap=null,this.combine=ue,this.reflectivity=1,this.refractionRatio=.98,this.wireframe=!1,this.wireframeLinewidth=1,this.wireframeLinecap="round",this.wireframeLinejoin="round",this.skinning=!1,this.morphTargets=!1,this.morphNormals=!1,this.setValues(e)}function Uo(e){aa.call(this),this.type="LineDashedMaterial",this.scale=1,this.dashSize=3,this.gapSize=1,this.setValues(e)}Io.prototype=Object.create($i.prototype),Io.prototype.constructor=Io,Io.prototype.isShadowMaterial=!0,Io.prototype.copy=function(e){return $i.prototype.copy.call(this,e),this.color.copy(e.color),this},_o.prototype=Object.create(tn.prototype),_o.prototype.constructor=_o,_o.prototype.isRawShaderMaterial=!0,So.prototype=Object.create($i.prototype),So.prototype.constructor=So,So.prototype.isMeshStandardMaterial=!0,So.prototype.copy=function(e){return $i.prototype.copy.call(this,e),this.defines={STANDARD:""},this.color.copy(e.color),this.roughness=e.roughness,this.metalness=e.metalness,this.map=e.map,this.lightMap=e.lightMap,this.lightMapIntensity=e.lightMapIntensity,this.aoMap=e.aoMap,this.aoMapIntensity=e.aoMapIntensity,this.emissive.copy(e.emissive),this.emissiveMap=e.emissiveMap,this.emissiveIntensity=e.emissiveIntensity,this.bumpMap=e.bumpMap,this.bumpScale=e.bumpScale,this.normalMap=e.normalMap,this.normalScale.copy(e.normalScale),this.displacementMap=e.displacementMap,this.displacementScale=e.displacementScale,this.displacementBias=e.displacementBias,this.roughnessMap=e.roughnessMap,this.metalnessMap=e.metalnessMap,this.alphaMap=e.alphaMap,this.envMap=e.envMap,this.envMapIntensity=e.envMapIntensity,this.refractionRatio=e.refractionRatio,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.wireframeLinecap=e.wireframeLinecap,this.wireframeLinejoin=e.wireframeLinejoin,this.skinning=e.skinning,this.morphTargets=e.morphTargets,this.morphNormals=e.morphNormals,this},jo.prototype=Object.create(So.prototype),jo.prototype.constructor=jo,jo.prototype.isMeshPhysicalMaterial=!0,jo.prototype.copy=function(e){return So.prototype.copy.call(this,e),this.defines={PHYSICAL:""},this.reflectivity=e.reflectivity,this.clearCoat=e.clearCoat,this.clearCoatRoughness=e.clearCoatRoughness,this},Co.prototype=Object.create($i.prototype),Co.prototype.constructor=Co,Co.prototype.isMeshPhongMaterial=!0,Co.prototype.copy=function(e){return $i.prototype.copy.call(this,e),this.color.copy(e.color),this.specular.copy(e.specular),this.shininess=e.shininess,this.map=e.map,this.lightMap=e.lightMap,this.lightMapIntensity=e.lightMapIntensity,this.aoMap=e.aoMap,this.aoMapIntensity=e.aoMapIntensity,this.emissive.copy(e.emissive),this.emissiveMap=e.emissiveMap,this.emissiveIntensity=e.emissiveIntensity,this.bumpMap=e.bumpMap,this.bumpScale=e.bumpScale,this.normalMap=e.normalMap,this.normalScale.copy(e.normalScale),this.displacementMap=e.displacementMap,this.displacementScale=e.displacementScale,this.displacementBias=e.displacementBias,this.specularMap=e.specularMap,this.alphaMap=e.alphaMap,this.envMap=e.envMap,this.combine=e.combine,this.reflectivity=e.reflectivity,this.refractionRatio=e.refractionRatio,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.wireframeLinecap=e.wireframeLinecap,this.wireframeLinejoin=e.wireframeLinejoin,this.skinning=e.skinning,this.morphTargets=e.morphTargets,this.morphNormals=e.morphNormals,this},Oo.prototype=Object.create(Co.prototype),Oo.prototype.constructor=Oo,Oo.prototype.isMeshToonMaterial=!0,Oo.prototype.copy=function(e){return Co.prototype.copy.call(this,e),this.gradientMap=e.gradientMap,this},zo.prototype=Object.create($i.prototype),zo.prototype.constructor=zo,zo.prototype.isMeshNormalMaterial=!0,zo.prototype.copy=function(e){return $i.prototype.copy.call(this,e),this.bumpMap=e.bumpMap,this.bumpScale=e.bumpScale,this.normalMap=e.normalMap,this.normalScale.copy(e.normalScale),this.displacementMap=e.displacementMap,this.displacementScale=e.displacementScale,this.displacementBias=e.displacementBias,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.skinning=e.skinning,this.morphTargets=e.morphTargets,this.morphNormals=e.morphNormals,this},Ro.prototype=Object.create($i.prototype),Ro.prototype.constructor=Ro,Ro.prototype.isMeshLambertMaterial=!0,Ro.prototype.copy=function(e){return $i.prototype.copy.call(this,e),this.color.copy(e.color),this.map=e.map,this.lightMap=e.lightMap,this.lightMapIntensity=e.lightMapIntensity,this.aoMap=e.aoMap,this.aoMapIntensity=e.aoMapIntensity,this.emissive.copy(e.emissive),this.emissiveMap=e.emissiveMap,this.emissiveIntensity=e.emissiveIntensity,this.specularMap=e.specularMap,this.alphaMap=e.alphaMap,this.envMap=e.envMap,this.combine=e.combine,this.reflectivity=e.reflectivity,this.refractionRatio=e.refractionRatio,this.wireframe=e.wireframe,this.wireframeLinewidth=e.wireframeLinewidth,this.wireframeLinecap=e.wireframeLinecap,this.wireframeLinejoin=e.wireframeLinejoin,this.skinning=e.skinning,this.morphTargets=e.morphTargets,this.morphNormals=e.morphNormals,this},Uo.prototype=Object.create(aa.prototype),Uo.prototype.constructor=Uo,Uo.prototype.isLineDashedMaterial=!0,Uo.prototype.copy=function(e){return aa.prototype.copy.call(this,e),this.scale=e.scale,this.dashSize=e.dashSize,this.gapSize=e.gapSize,this};var Po=Object.freeze({ShadowMaterial:Io,SpriteMaterial:$r,RawShaderMaterial:_o,ShaderMaterial:tn,PointsMaterial:ua,MeshPhysicalMaterial:jo,MeshStandardMaterial:So,MeshPhongMaterial:Co,MeshToonMaterial:Oo,MeshNormalMaterial:zo,MeshLambertMaterial:Ro,MeshDepthMaterial:Ur,MeshDistanceMaterial:Pr,MeshBasicMaterial:en,LineDashedMaterial:Uo,LineBasicMaterial:aa,Material:$i}),Bo={enabled:!1,files:{},add:function(e,t){!1!==this.enabled&&(this.files[e]=t)},get:function(e){if(!1!==this.enabled)return this.files[e]},remove:function(e){delete this.files[e]},clear:function(){this.files={}}};function ko(e,t,i){var n=this,r=!1,a=0,o=0,s=void 0;this.onStart=void 0,this.onLoad=e,this.onProgress=t,this.onError=i,this.itemStart=function(e){o++,!1===r&&void 0!==n.onStart&&n.onStart(e,a,o),r=!0},this.itemEnd=function(e){a++,void 0!==n.onProgress&&n.onProgress(e,a,o),a===o&&(r=!1,void 0!==n.onLoad&&n.onLoad())},this.itemError=function(e){void 0!==n.onError&&n.onError(e)},this.resolveURL=function(e){return s?s(e):e},this.setURLModifier=function(e){return s=e,this}}var Fo=new ko,Go={};function Qo(e){this.manager=void 0!==e?e:Fo}function Yo(e){this.manager=void 0!==e?e:Fo}function Vo(e){this.manager=void 0!==e?e:Fo}function Ho(){this.type="Curve",this.arcLengthDivisions=200}function Wo(e,t,i,n,r,a,o,s){Ho.call(this),this.type="EllipseCurve",this.aX=e||0,this.aY=t||0,this.xRadius=i||1,this.yRadius=n||1,this.aStartAngle=r||0,this.aEndAngle=a||2*Math.PI,this.aClockwise=o||!1,this.aRotation=s||0}function Xo(e,t,i,n,r,a){Wo.call(this,e,t,i,i,n,r,a),this.type="ArcCurve"}function Zo(){var e=0,t=0,i=0,n=0;function r(r,a,o,s){e=r,t=o,i=-3*r+3*a-2*o-s,n=2*r-2*a+o+s}return{initCatmullRom:function(e,t,i,n,a){r(t,i,a*(i-e),a*(n-t))},initNonuniformCatmullRom:function(e,t,i,n,a,o,s){var c=(t-e)/a-(i-e)/(a+o)+(i-t)/o,u=(i-t)/o-(n-t)/(o+s)+(n-i)/s;r(t,i,c*=o,u*=o)},calc:function(r){var a=r*r;return e+t*r+i*a+n*(a*r)}}}Object.assign(Qo.prototype,{load:function(e,t,i,n){void 0===e&&(e=""),void 0!==this.path&&(e=this.path+e),e=this.manager.resolveURL(e);var r=this,a=Bo.get(e);if(void 0!==a)return r.manager.itemStart(e),setTimeout(function(){t&&t(a),r.manager.itemEnd(e)},0),a;if(void 0===Go[e]){var o=e.match(/^data:(.*?)(;base64)?,(.*)$/);if(o){var s=o[1],c=!!o[2],u=o[3];u=window.decodeURIComponent(u),c&&(u=window.atob(u));try{var h,l=(this.responseType||"").toLowerCase();switch(l){case"arraybuffer":case"blob":for(var d=new Uint8Array(u.length),p=0;p0||0===e.search(/^data\:image\/jpeg/);r.format=n?He:We,r.needsUpdate=!0,void 0!==t&&t(r)},i,n),r},setCrossOrigin:function(e){return this.crossOrigin=e,this},setPath:function(e){return this.path=e,this}}),Object.assign(Ho.prototype,{getPoint:function(){return console.warn("THREE.Curve: .getPoint() not implemented."),null},getPointAt:function(e,t){var i=this.getUtoTmapping(e);return this.getPoint(i,t)},getPoints:function(e){void 0===e&&(e=5);for(var t=[],i=0;i<=e;i++)t.push(this.getPoint(i/e));return t},getSpacedPoints:function(e){void 0===e&&(e=5);for(var t=[],i=0;i<=e;i++)t.push(this.getPointAt(i/e));return t},getLength:function(){var e=this.getLengths();return e[e.length-1]},getLengths:function(e){if(void 0===e&&(e=this.arcLengthDivisions),this.cacheArcLengths&&this.cacheArcLengths.length===e+1&&!this.needsUpdate)return this.cacheArcLengths;this.needsUpdate=!1;var t,i,n=[],r=this.getPoint(0),a=0;for(n.push(0),i=1;i<=e;i++)a+=(t=this.getPoint(i/e)).distanceTo(r),n.push(a),r=t;return this.cacheArcLengths=n,n},updateArcLengths:function(){this.needsUpdate=!0,this.getLengths()},getUtoTmapping:function(e,t){var i,n=this.getLengths(),r=0,a=n.length;i=t||e*n[a-1];for(var o,s=0,c=a-1;s<=c;)if((o=n[r=Math.floor(s+(c-s)/2)]-i)<0)s=r+1;else{if(!(o>0)){c=r;break}c=r-1}if(n[r=c]===i)return r/(a-1);var u=n[r];return(r+(i-u)/(n[r+1]-u))/(a-1)},getTangent:function(e){var t=e-1e-4,i=e+1e-4;t<0&&(t=0),i>1&&(i=1);var n=this.getPoint(t);return this.getPoint(i).clone().sub(n).normalize()},getTangentAt:function(e){var t=this.getUtoTmapping(e);return this.getTangent(t)},computeFrenetFrames:function(e,t){var i,n,r,a=new Bt,o=[],s=[],c=[],u=new Bt,h=new Ut;for(i=0;i<=e;i++)n=i/e,o[i]=this.getTangentAt(n),o[i].normalize();s[0]=new Bt,c[0]=new Bt;var l=Number.MAX_VALUE,d=Math.abs(o[0].x),p=Math.abs(o[0].y),f=Math.abs(o[0].z);for(d<=l&&(l=d,a.set(1,0,0)),p<=l&&(l=p,a.set(0,1,0)),f<=l&&a.set(0,0,1),u.crossVectors(o[0],a).normalize(),s[0].crossVectors(o[0],u),c[0].crossVectors(o[0],s[0]),i=1;i<=e;i++)s[i]=s[i-1].clone(),c[i]=c[i-1].clone(),u.crossVectors(o[i-1],o[i]),u.length()>Number.EPSILON&&(u.normalize(),r=Math.acos(zt.clamp(o[i-1].dot(o[i]),-1,1)),s[i].applyMatrix4(h.makeRotationAxis(u,r))),c[i].crossVectors(o[i],s[i]);if(!0===t)for(r=Math.acos(zt.clamp(s[0].dot(s[e]),-1,1)),r/=e,o[0].dot(u.crossVectors(s[0],s[e]))>0&&(r=-r),i=1;i<=e;i++)s[i].applyMatrix4(h.makeRotationAxis(o[i],r*i)),c[i].crossVectors(o[i],s[i]);return{tangents:o,normals:s,binormals:c}},clone:function(){return(new this.constructor).copy(this)},copy:function(e){return this.arcLengthDivisions=e.arcLengthDivisions,this},toJSON:function(){var e={metadata:{version:4.5,type:"Curve",generator:"Curve.toJSON"}};return e.arcLengthDivisions=this.arcLengthDivisions,e.type=this.type,e},fromJSON:function(e){return this.arcLengthDivisions=e.arcLengthDivisions,this}}),Wo.prototype=Object.create(Ho.prototype),Wo.prototype.constructor=Wo,Wo.prototype.isEllipseCurve=!0,Wo.prototype.getPoint=function(e,t){for(var i=t||new Rt,n=2*Math.PI,r=this.aEndAngle-this.aStartAngle,a=Math.abs(r)n;)r-=n;r0?0:(Math.floor(Math.abs(h)/c)+1)*c:0===l&&h===c-1&&(h=c-2,l=1),this.closed||h>0?i=s[(h-1)%c]:(qo.subVectors(s[0],s[1]).add(s[0]),i=qo),n=s[h%c],r=s[(h+1)%c],this.closed||h+2n.length-2?n.length-1:a+1],h=n[a>n.length-3?n.length-1:a+2];return i.set(ts(o,s.x,c.x,u.x,h.x),ts(o,s.y,c.y,u.y,h.y)),i},hs.prototype.copy=function(e){Ho.prototype.copy.call(this,e),this.points=[];for(var t=0,i=e.points.length;t=t){var r=i[n]-t,a=this.curves[n],o=a.getLength(),s=0===o?0:1-r/o;return a.getPointAt(s)}n++}return null},getLength:function(){var e=this.getCurveLengths();return e[e.length-1]},updateArcLengths:function(){this.needsUpdate=!0,this.cacheLengths=null,this.getCurveLengths()},getCurveLengths:function(){if(this.cacheLengths&&this.cacheLengths.length===this.curves.length)return this.cacheLengths;for(var e=[],t=0,i=0,n=this.curves.length;i1&&!i[i.length-1].equals(i[0])&&i.push(i[0]),i},copy:function(e){Ho.prototype.copy.call(this,e),this.curves=[];for(var t=0,i=e.curves.length;t0){var u=c.getPoint(0);u.equals(this.currentPoint)||this.lineTo(u.x,u.y)}this.curves.push(c);var h=c.getPoint(1);this.currentPoint.copy(h)},copy:function(e){return ds.prototype.copy.call(this,e),this.currentPoint.copy(e.currentPoint),this},toJSON:function(){var e=ds.prototype.toJSON.call(this);return e.currentPoint=this.currentPoint.toArray(),e},fromJSON:function(e){return ds.prototype.fromJSON.call(this,e),this.currentPoint.fromArray(e.currentPoint),this}}),fs.prototype=Object.assign(Object.create(ps.prototype),{constructor:fs,getPointsHoles:function(e){for(var t=[],i=0,n=this.holes.length;i=r)break e;var s=t[1];e=(r=t[--i-1]))break t}a=i,i=0}for(;i>>1;et;)--a;if(++a,0!==r||a!==n){r>=a&&(r=(a=Math.max(a,1))-1);var o=this.getValueSize();this.times=zs.arraySlice(i,r,a),this.values=zs.arraySlice(this.values,r*o,a*o)}return this},validate:function(){var e=!0,t=this.getValueSize();t-Math.floor(t)!=0&&(console.error("THREE.KeyframeTrack: Invalid value size in track.",this),e=!1);var i=this.times,n=this.values,r=i.length;0===r&&(console.error("THREE.KeyframeTrack: Track is empty.",this),e=!1);for(var a=null,o=0;o!==r;o++){var s=i[o];if("number"==typeof s&&isNaN(s)){console.error("THREE.KeyframeTrack: Time is not a valid number.",this,o,s),e=!1;break}if(null!==a&&a>s){console.error("THREE.KeyframeTrack: Out of order keys.",this,o,s,a),e=!1;break}a=s}if(void 0!==n&&zs.isTypedArray(n)){o=0;for(var c=n.length;o!==c;++o){var u=n[o];if(isNaN(u)){console.error("THREE.KeyframeTrack: Value is not a valid number.",this,o,u),e=!1;break}}}return e},optimize:function(){for(var e=this.times,t=this.values,i=this.getValueSize(),n=2302===this.getInterpolation(),r=1,a=e.length-1,o=1;o0){e[r]=e[a];for(f=a*i,g=r*i,d=0;d!==i;++d)t[g+d]=t[f+d];++r}return r!==e.length&&(this.times=zs.arraySlice(e,0,r),this.values=zs.arraySlice(t,0,r*i)),this}}),Us.prototype=Object.assign(Object.create(Rs.prototype),{constructor:Us,ValueTypeName:"vector"}),Object.assign(Ps,{parse:function(e){for(var t=[],i=e.tracks,n=1/(e.fps||1),r=0,a=i.length;r!==a;++r)t.push(Rs.parse(i[r]).scale(n));return new Ps(e.name,e.duration,t)},toJSON:function(e){for(var t=[],i=e.tracks,n={name:e.name,duration:e.duration,tracks:t,uuid:e.uuid},r=0,a=i.length;r!==a;++r)t.push(Rs.toJSON(i[r]));return n},CreateFromMorphTargetSequence:function(e,t,i,n){for(var r=t.length,a=[],o=0;o1){var u=n[l=c[1]];u||(n[l]=u=[]),u.push(s)}}var h=[];for(var l in n)h.push(Ps.CreateFromMorphTargetSequence(l,n[l],t,i));return h},parseAnimation:function(e,t){if(!e)return console.error("THREE.AnimationClip: No animation in JSONLoader data."),null;for(var i=function(e,t,i,n,r){if(0!==i.length){var a=[],o=[];zs.flattenJSON(i,a,o,n),0!==a.length&&r.push(new e(t,a,o))}},n=[],r=e.name||"default",a=e.length||-1,o=e.fps||30,s=e.hierarchy||[],c=0;c1?e.skinWeights[n+1]:0,s=i>2?e.skinWeights[n+2]:0,c=i>3?e.skinWeights[n+3]:0;t.skinWeights.push(new Xt(a,o,s,c))}if(e.skinIndices)for(n=0,r=e.skinIndices.length;n1?e.skinIndices[n+1]:0,l=i>2?e.skinIndices[n+2]:0,d=i>3?e.skinIndices[n+3]:0;t.skinIndices.push(new Xt(u,h,l,d))}t.bones=e.bones,t.bones&&t.bones.length>0&&(t.skinWeights.length!==t.skinIndices.length||t.skinIndices.length!==t.vertices.length)&&console.warn("When skinning, number of vertices ("+t.vertices.length+"), skinIndices ("+t.skinIndices.length+"), and skinWeights ("+t.skinWeights.length+") should match.")}(e,i),function(e,t){var i=e.scale;if(void 0!==e.morphTargets)for(var n=0,r=e.morphTargets.length;n0){console.warn('THREE.JSONLoader: "morphColors" no longer supported. Using them as face colors.');var h=t.faces,l=e.morphColors[0].colors;for(n=0,r=h.length;n0&&(t.animations=i)}(e,i),i.computeFaceNormals(),i.computeBoundingSphere(),void 0===e.materials||0===e.materials.length?{geometry:i}:{geometry:i,materials:Hs.prototype.initMaterials(e.materials,t,this.crossOrigin)}}}),Object.assign(function(e){this.manager=void 0!==e?e:Fo,this.texturePath=""}.prototype,{load:function(e,t,i,n){""===this.texturePath&&(this.texturePath=e.substring(0,e.lastIndexOf("/")+1));var r=this;new Qo(r.manager).load(e,function(i){var a=null;try{a=JSON.parse(i)}catch(t){return void 0!==n&&n(t),void console.error("THREE:ObjectLoader: Can't parse "+e+".",t.message)}var o=a.metadata;void 0!==o&&void 0!==o.type&&"geometry"!==o.type.toLowerCase()?r.parse(a,t):console.error("THREE.ObjectLoader: Can't load "+e+". Use THREE.JSONLoader instead.")},i,n)},setTexturePath:function(e){return this.texturePath=e,this},setCrossOrigin:function(e){return this.crossOrigin=e,this},parse:function(e,t){var i=this.parseShape(e.shapes),n=this.parseGeometries(e.geometries,i),r=this.parseImages(e.images,function(){void 0!==t&&t(s)}),a=this.parseTextures(e.textures,r),o=this.parseMaterials(e.materials,a),s=this.parseObject(e.object,n,o);return e.animations&&(s.animations=this.parseAnimations(e.animations)),void 0!==e.images&&0!==e.images.length||void 0!==t&&t(s),s},parseShape:function(e){var t={};if(void 0!==e)for(var i=0,n=e.length;i0){var a=new Yo(new ko(t));a.setCrossOrigin(this.crossOrigin);for(var o=0,s=e.length;o0?new ra(o,s):new on(o,s);break;case"LOD":n=new ta;break;case"Line":n=new oa(r(e.geometry),a(e.material),e.mode);break;case"LineLoop":n=new ca(r(e.geometry),a(e.material));break;case"LineSegments":n=new sa(r(e.geometry),a(e.material));break;case"PointCloud":case"Points":n=new ha(r(e.geometry),a(e.material));break;case"Sprite":n=new ea(a(e.material));break;case"Group":n=new la;break;default:n=new vi}if(n.uuid=e.uuid,void 0!==e.name&&(n.name=e.name),void 0!==e.matrix?(n.matrix.fromArray(e.matrix),void 0!==e.matrixAutoUpdate&&(n.matrixAutoUpdate=e.matrixAutoUpdate),n.matrixAutoUpdate&&n.matrix.decompose(n.position,n.quaternion,n.scale)):(void 0!==e.position&&n.position.fromArray(e.position),void 0!==e.rotation&&n.rotation.fromArray(e.rotation),void 0!==e.quaternion&&n.quaternion.fromArray(e.quaternion),void 0!==e.scale&&n.scale.fromArray(e.scale)),void 0!==e.castShadow&&(n.castShadow=e.castShadow),void 0!==e.receiveShadow&&(n.receiveShadow=e.receiveShadow),e.shadow&&(void 0!==e.shadow.bias&&(n.shadow.bias=e.shadow.bias),void 0!==e.shadow.radius&&(n.shadow.radius=e.shadow.radius),void 0!==e.shadow.mapSize&&n.shadow.mapSize.fromArray(e.shadow.mapSize),void 0!==e.shadow.camera&&(n.shadow.camera=this.parseObject(e.shadow.camera))),void 0!==e.visible&&(n.visible=e.visible),void 0!==e.frustumCulled&&(n.frustumCulled=e.frustumCulled),void 0!==e.renderOrder&&(n.renderOrder=e.renderOrder),void 0!==e.userData&&(n.userData=e.userData),void 0!==e.children)for(var c=e.children,u=0;uNumber.EPSILON){if(u<0&&(o=t[a],c=-c,s=t[r],u=-u),e.ys.y)continue;if(e.y===o.y){if(e.x===o.x)return!0}else{var h=u*(e.x-o.x)-c*(e.y-o.y);if(0===h)return!0;if(h<0)continue;n=!n}}else{if(e.y!==o.y)continue;if(s.x<=e.x&&e.x<=o.x||o.x<=e.x&&e.x<=s.x)return!0}}return n}var r=io.isClockWise,a=this.subPaths;if(0===a.length)return[];if(!0===t)return i(a);var o,s,c,u=[];if(1===a.length)return s=a[0],(c=new fs).curves=s.curves,u.push(c),u;var h=!r(a[0].getPoints());h=e?!h:h;var l,d,p=[],f=[],g=[],m=0;f[m]=void 0,g[m]=[];for(var M=0,y=a.length;M1){for(var v=!1,A=[],w=0,x=f.length;w0&&(v||(g=p))}M=0;for(var b=f.length;M0){this.source.connect(this.filters[0]);for(var e=1,t=this.filters.length;e0){this.source.disconnect(this.filters[0]);for(var e=1,t=this.filters.length;e=.5)for(var a=0;a!==r;++a)e[t+a]=e[i+a]},_slerp:function(e,t,i,n){Pt.slerpFlat(e,t,e,t,e,i,n)},_lerp:function(e,t,i,n,r){for(var a=1-n,o=0;o!==r;++o){var s=t+o;e[s]=e[s]*a+e[i+o]*n}}});var Ac,wc,xc,Ec,Tc,Nc,Dc,Lc,bc,Ic,_c,Sc,jc;function Cc(e,t,i){var n=i||Oc.parseTrackName(t);this._targetGroup=e,this._bindings=e.subscribe_(t,n)}function Oc(e,t,i){this.path=t,this.parsedPath=i||Oc.parseTrackName(t),this.node=Oc.findNode(e,this.parsedPath.nodeName)||e,this.rootNode=e}function zc(e,t,i){this._mixer=e,this._clip=t,this._localRoot=i||null;for(var n=t.tracks,r=n.length,a=new Array(r),o={endingStart:xt,endingEnd:xt},s=0;s!==r;++s){var c=n[s].createInterpolant(null);a[s]=c,c.settings=o}this._interpolantSettings=o,this._interpolants=a,this._propertyBindings=new Array(r),this._cacheIndex=null,this._byClipCacheIndex=null,this._timeScaleInterpolant=null,this._weightInterpolant=null,this.loop=wt,this._loopCount=-1,this._startTime=null,this.time=0,this.timeScale=1,this._effectiveTimeScale=1,this.weight=1,this._effectiveWeight=1,this.repetitions=1/0,this.paused=!1,this.enabled=!0,this.clampWhenFinished=!1,this.zeroSlopeAtStart=!0,this.zeroSlopeAtEnd=!0}function Rc(e){this._root=e,this._initMemoryManager(),this._accuIndex=0,this.time=0,this.timeScale=1}function Uc(e){"string"==typeof e&&(console.warn("THREE.Uniform: Type parameter is no longer needed."),e=arguments[1]),this.value=e}function Pc(){ki.call(this),this.type="InstancedBufferGeometry",this.maxInstancedCount=void 0}function Bc(e,t,i,n){this.data=e,this.itemSize=t,this.offset=i,this.normalized=!0===n}function kc(e,t){this.array=e,this.stride=t,this.count=void 0!==e?e.length/t:0,this.dynamic=!1,this.updateRange={offset:0,count:-1},this.version=0}function Fc(e,t,i){kc.call(this,e,t),this.meshPerAttribute=i||1}function Gc(e,t,i){Li.call(this,e,t),this.meshPerAttribute=i||1}function Qc(e,t){return e.distance-t.distance}function Yc(e,t,i,n){if(!1!==e.visible&&(e.raycast(t,i),!0===n))for(var r=e.children,a=0,o=r.length;a=t){var h=t++,l=e[h];i[l.uuid]=u,e[u]=l,i[c]=h,e[h]=s;for(var d=0,p=r;d!==p;++d){var f=n[d],g=f[h],m=f[u];f[u]=g,f[h]=m}}}this.nCachedObjects_=t},uncache:function(){for(var e=this._objects,t=e.length,i=this.nCachedObjects_,n=this._indicesByUUID,r=this._bindings,a=r.length,o=0,s=arguments.length;o!==s;++o){var c=arguments[o],u=c.uuid,h=n[u];if(void 0!==h)if(delete n[u],h0)for(var c=this._interpolants,u=this._propertyBindings,h=0,l=c.length;h!==l;++h)c[h].evaluate(o),u[h].accumulate(n,s)}else this._updateWeight(e)},_updateWeight:function(e){var t=0;if(this.enabled){t=this.weight;var i=this._weightInterpolant;if(null!==i){var n=i.evaluate(e)[0];t*=n,e>i.parameterPositions[1]&&(this.stopFading(),0===n&&(this.enabled=!1))}}return this._effectiveWeight=t,t},_updateTimeScale:function(e){var t=0;if(!this.paused){t=this.timeScale;var i=this._timeScaleInterpolant;if(null!==i)t*=i.evaluate(e)[0],e>i.parameterPositions[1]&&(this.stopWarping(),0===t?this.paused=!0:this.timeScale=t)}return this._effectiveTimeScale=t,t},_updateTime:function(e){var t=this.time+e;if(0===e)return t;var i=this._clip.duration,n=this.loop,r=this._loopCount;if(2200===n){-1===r&&(this._loopCount=0,this._setEndings(!0,!0,!1));e:{if(t>=i)t=i;else{if(!(t<0))break e;t=0}this.clampWhenFinished?this.paused=!0:this.enabled=!1,this._mixer.dispatchEvent({type:"finished",action:this,direction:e<0?-1:1})}}else{var a=2202===n;if(-1===r&&(e>=0?(r=0,this._setEndings(!0,0===this.repetitions,a)):this._setEndings(0===this.repetitions,!0,a)),t>=i||t<0){var o=Math.floor(t/i);t-=i*o,r+=Math.abs(o);var s=this.repetitions-r;if(s<=0)this.clampWhenFinished?this.paused=!0:this.enabled=!1,t=e>0?i:0,this._mixer.dispatchEvent({type:"finished",action:this,direction:e>0?1:-1});else{if(1===s){var c=e<0;this._setEndings(c,!c,a)}else this._setEndings(!1,!1,a);this._loopCount=r,this._mixer.dispatchEvent({type:"loop",action:this,loopDelta:o})}}if(a&&1==(1&r))return this.time=t,i-t}return this.time=t,t},_setEndings:function(e,t,i){var n=this._interpolantSettings;i?(n.endingStart=2401,n.endingEnd=2401):(n.endingStart=e?this.zeroSlopeAtStart?2401:xt:2402,n.endingEnd=t?this.zeroSlopeAtEnd?2401:xt:2402)},_scheduleFading:function(e,t,i){var n=this._mixer,r=n.time,a=this._weightInterpolant;null===a&&(a=n._lendControlInterpolant(),this._weightInterpolant=a);var o=a.parameterPositions,s=a.sampleValues;return o[0]=r,s[0]=t,o[1]=r+e,s[1]=i,this}}),Rc.prototype=Object.assign(Object.create(u.prototype),{constructor:Rc,_bindAction:function(e,t){var i=e._localRoot||this._root,n=e._clip.tracks,r=n.length,a=e._propertyBindings,o=e._interpolants,s=i.uuid,c=this._bindingsByRootAndName,u=c[s];void 0===u&&(u={},c[s]=u);for(var h=0;h!==r;++h){var l=n[h],d=l.name,p=u[d];if(void 0!==p)a[h]=p;else{if(void 0!==(p=a[h])){null===p._cacheIndex&&(++p.referenceCount,this._addInactiveBinding(p,s,d));continue}var f=t&&t._propertyBindings[h].binding.parsedPath;++(p=new vc(Oc.create(i,d,f),l.ValueTypeName,l.getValueSize())).referenceCount,this._addInactiveBinding(p,s,d),a[h]=p}o[h].resultBuffer=p.buffer}},_activateAction:function(e){if(!this._isActiveAction(e)){if(null===e._cacheIndex){var t=(e._localRoot||this._root).uuid,i=e._clip.uuid,n=this._actionsByClip[i];this._bindAction(e,n&&n.knownActions[0]),this._addInactiveAction(e,i,t)}for(var r=e._propertyBindings,a=0,o=r.length;a!==o;++a){var s=r[a];0==s.useCount++&&(this._lendBinding(s),s.saveOriginalState())}this._lendAction(e)}},_deactivateAction:function(e){if(this._isActiveAction(e)){for(var t=e._propertyBindings,i=0,n=t.length;i!==n;++i){var r=t[i];0==--r.useCount&&(r.restoreOriginalState(),this._takeBackBinding(r))}this._takeBackAction(e)}},_initMemoryManager:function(){this._actions=[],this._nActiveActions=0,this._actionsByClip={},this._bindings=[],this._nActiveBindings=0,this._bindingsByRootAndName={},this._controlInterpolants=[],this._nActiveControlInterpolants=0;var e=this;this.stats={actions:{get total(){return e._actions.length},get inUse(){return e._nActiveActions}},bindings:{get total(){return e._bindings.length},get inUse(){return e._nActiveBindings}},controlInterpolants:{get total(){return e._controlInterpolants.length},get inUse(){return e._nActiveControlInterpolants}}}},_isActiveAction:function(e){var t=e._cacheIndex;return null!==t&&tthis.max.x||e.ythis.max.y)},containsBox:function(e){return this.min.x<=e.min.x&&e.max.x<=this.max.x&&this.min.y<=e.min.y&&e.max.y<=this.max.y},getParameter:function(e,t){return void 0===t&&(console.warn("THREE.Box2: .getParameter() target is now required"),t=new Rt),t.set((e.x-this.min.x)/(this.max.x-this.min.x),(e.y-this.min.y)/(this.max.y-this.min.y))},intersectsBox:function(e){return!(e.max.xthis.max.x||e.max.ythis.max.y)},clampPoint:function(e,t){return void 0===t&&(console.warn("THREE.Box2: .clampPoint() target is now required"),t=new Rt),t.copy(e).clamp(this.min,this.max)},distanceToPoint:function(){var e=new Rt;return function(t){return e.copy(t).clamp(this.min,this.max).sub(t).length()}}(),intersect:function(e){return this.min.max(e.min),this.max.min(e.max),this},union:function(e){return this.min.min(e.min),this.max.max(e.max),this},translate:function(e){return this.min.add(e),this.max.add(e),this},equals:function(e){return e.min.equals(this.min)&&e.max.equals(this.max)}}),Wc.prototype=Object.create(vi.prototype),Wc.prototype.constructor=Wc,Wc.prototype.isImmediateRenderObject=!0,Xc.prototype=Object.create(sa.prototype),Xc.prototype.constructor=Xc,Xc.prototype.update=function(){var e=new Bt,t=new Bt,i=new kt;return function(){var n=["a","b","c"];this.object.updateMatrixWorld(!0),i.getNormalMatrix(this.object.matrixWorld);var r=this.object.matrixWorld,a=this.geometry.attributes.position,o=this.object.geometry;if(o&&o.isGeometry)for(var s=o.vertices,c=o.faces,u=0,h=0,l=c.length;h.99999?this.quaternion.set(0,0,0,1):e.y<-.99999?this.quaternion.set(1,0,0,0):(jc.set(e.z,0,-e.x).normalize(),Sc=Math.acos(e.y),this.quaternion.setFromAxisAngle(jc,Sc))}),cu.prototype.setLength=function(e,t,i){void 0===t&&(t=.2*e),void 0===i&&(i=.2*t),this.line.scale.set(1,Math.max(0,e-t),1),this.line.updateMatrix(),this.cone.scale.set(i,t,i),this.cone.position.y=e,this.cone.updateMatrix()},cu.prototype.setColor=function(e){this.line.material.color.copy(e),this.cone.material.color.copy(e)},uu.prototype=Object.create(sa.prototype),uu.prototype.constructor=uu,Ho.create=function(e,t){return console.log("THREE.Curve.create() has been deprecated"),e.prototype=Object.create(Ho.prototype),e.prototype.constructor=e,e.prototype.getPoint=t,e},Object.assign(ds.prototype,{createPointsGeometry:function(e){console.warn("THREE.CurvePath: .createPointsGeometry() has been removed. Use new THREE.Geometry().setFromPoints( points ) instead.");var t=this.getPoints(e);return this.createGeometry(t)},createSpacedPointsGeometry:function(e){console.warn("THREE.CurvePath: .createSpacedPointsGeometry() has been removed. Use new THREE.Geometry().setFromPoints( points ) instead.");var t=this.getSpacedPoints(e);return this.createGeometry(t)},createGeometry:function(e){console.warn("THREE.CurvePath: .createGeometry() has been removed. Use new THREE.Geometry().setFromPoints( points ) instead.");for(var t=new Di,i=0,n=e.length;i0?i=e[0]:t&&t("VR input not available.")}).catch(function(){console.warn("VRControls: Unable to get VR Displays")}),this.scale=1,this.standing=!1,this.userHeight=1.6,this.getVRDisplay=function(){return i},this.setVRDisplay=function(e){i=e},this.getVRDisplays=function(){return console.warn("VRControls: getVRDisplays() is being deprecated."),n},this.getStandingMatrix=function(){return a},this.update=function(){var t;i&&(i.getFrameData?(i.getFrameData(o),t=o.pose):i.getPose&&(t=i.getPose()),null!==t.orientation&&e.quaternion.fromArray(t.orientation),null!==t.position?e.position.fromArray(t.position):e.position.set(0,0,0),this.standing&&(i.stageParameters?(e.updateMatrix(),a.fromArray(i.stageParameters.sittingToStandingTransform),e.applyMatrix(a)):e.position.setY(e.position.y+this.userHeight)),e.position.multiplyScalar(r.scale))},this.dispose=function(){i=null}},du=function(e,t){var i,n,r,a,o=new Bt,s=new Bt,c=new Ut,u=new Ut,h=new Ut,l=null;"VRFrameData"in window&&(l=new window.VRFrameData),navigator.getVRDisplays&&navigator.getVRDisplays().then(function(e){n=e,e.length>0?i=e[0]:t&&t("HMD not available")}).catch(function(){console.warn("VREffect: Unable to get VR Displays")}),this.isPresenting=!1;var d=this,p=e.getSize(),f=!1,g=e.getPixelRatio();this.getVRDisplay=function(){return i},this.setVRDisplay=function(e){i=e},this.getVRDisplays=function(){return console.warn("VREffect: getVRDisplays() is being deprecated."),n},this.setSize=function(t,n,r){if(p={width:t,height:n},f=r,d.isPresenting){var a=i.getEyeParameters("left");e.setPixelRatio(1),e.setSize(2*a.renderWidth,a.renderHeight,!1)}else e.setPixelRatio(g),e.setSize(t,n,r)};var m=e.domElement,M=[0,0,.5,1],y=[.5,0,.5,1];function v(){var t=d.isPresenting;if(d.isPresenting=void 0!==i&&i.isPresenting,d.isPresenting){var n=i.getEyeParameters("left"),r=n.renderWidth,a=n.renderHeight;t||(g=e.getPixelRatio(),p=e.getSize(),e.setPixelRatio(1),e.setSize(2*r,a,!1))}else t&&(e.setPixelRatio(g),e.setSize(p.width,p.height,f))}window.addEventListener("vrdisplaypresentchange",v,!1),this.setFullScreen=function(e){return new Promise(function(t,n){void 0!==i?d.isPresenting!==e?t(e?i.requestPresent([{source:m}]):i.exitPresent()):t():n(new Error("No VR hardware found."))})},this.requestPresent=function(){return this.setFullScreen(!0)},this.exitPresent=function(){return this.setFullScreen(!1)},this.requestAnimationFrame=function(e){return void 0!==i?i.requestAnimationFrame(e):window.requestAnimationFrame(e)},this.cancelAnimationFrame=function(e){void 0!==i?i.cancelAnimationFrame(e):window.cancelAnimationFrame(e)},this.submitFrame=function(){void 0!==i&&d.isPresenting&&i.submitFrame()},this.autoSubmitFrame=!0;var A=new Vr;A.layers.enable(1);var w=new Vr;w.layers.enable(2),this.render=function(t,n,p,f){if(i&&d.isPresenting){var g=t.autoUpdate;g&&(t.updateMatrixWorld(),t.autoUpdate=!1),Array.isArray(t)&&(console.warn("VREffect.render() no longer supports arrays. Use object.layers instead."),t=t[0]);var m,v,N=e.getSize(),D=i.getLayers();if(D.length){var L=D[0];m=null!==L.leftBounds&&4===L.leftBounds.length?L.leftBounds:M,v=null!==L.rightBounds&&4===L.rightBounds.length?L.rightBounds:y}else m=M,v=y;if(r={x:Math.round(N.width*m[0]),y:Math.round(N.height*m[1]),width:Math.round(N.width*m[2]),height:Math.round(N.height*m[3])},a={x:Math.round(N.width*v[0]),y:Math.round(N.height*v[1]),width:Math.round(N.width*v[2]),height:Math.round(N.height*v[3])},p?(e.setRenderTarget(p),p.scissorTest=!0):(e.setRenderTarget(null),e.setScissorTest(!0)),(e.autoClear||f)&&e.clear(),null===n.parent&&n.updateMatrixWorld(),n.matrixWorld.decompose(A.position,A.quaternion,A.scale),w.position.copy(A.position),w.quaternion.copy(A.quaternion),w.scale.copy(A.scale),i.getFrameData)i.depthNear=n.near,i.depthFar=n.far,i.getFrameData(l),A.projectionMatrix.elements=l.leftProjectionMatrix,w.projectionMatrix.elements=l.rightProjectionMatrix,function(e){e.pose.orientation?(x.fromArray(e.pose.orientation),c.makeRotationFromQuaternion(x)):c.identity();e.pose.position&&(E.fromArray(e.pose.position),c.setPosition(E));u.fromArray(e.leftViewMatrix),u.multiply(c),h.fromArray(e.rightViewMatrix),h.multiply(c),u.getInverse(u),h.getInverse(h)}(l),A.updateMatrix(),A.matrix.multiply(u),A.matrix.decompose(A.position,A.quaternion,A.scale),w.updateMatrix(),w.matrix.multiply(h),w.matrix.decompose(w.position,w.quaternion,w.scale);else{var b=i.getEyeParameters("left"),I=i.getEyeParameters("right");A.projectionMatrix=T(b.fieldOfView,!0,n.near,n.far),w.projectionMatrix=T(I.fieldOfView,!0,n.near,n.far),o.fromArray(b.offset),s.fromArray(I.offset),A.translateOnAxis(o,A.scale.x),w.translateOnAxis(s,w.scale.x)}return p?(p.viewport.set(r.x,r.y,r.width,r.height),p.scissor.set(r.x,r.y,r.width,r.height)):(e.setViewport(r.x,r.y,r.width,r.height),e.setScissor(r.x,r.y,r.width,r.height)),e.render(t,A,p,f),p?(p.viewport.set(a.x,a.y,a.width,a.height),p.scissor.set(a.x,a.y,a.width,a.height)):(e.setViewport(a.x,a.y,a.width,a.height),e.setScissor(a.x,a.y,a.width,a.height)),e.render(t,w,p,f),p?(p.viewport.set(0,0,N.width,N.height),p.scissor.set(0,0,N.width,N.height),p.scissorTest=!1,e.setRenderTarget(null)):(e.setViewport(0,0,N.width,N.height),e.setScissorTest(!1)),g&&(t.autoUpdate=!0),void(d.autoSubmitFrame&&d.submitFrame())}e.render(t,n,p,f)},this.dispose=function(){window.removeEventListener("vrdisplaypresentchange",v,!1)};var x=new Pt,E=new Bt;function T(e,t,i,n){var r=Math.PI/180;return function(e,t,i,n){i=void 0===i?.01:i,n=void 0===n?1e4:n;var r=(t=void 0===t||t)?-1:1,a=new Ut,o=a.elements,s=function(e){var t=2/(e.leftTan+e.rightTan),i=(e.leftTan-e.rightTan)*t*.5,n=2/(e.upTan+e.downTan);return{scale:[t,n],offset:[i,(e.upTan-e.downTan)*n*.5]}}(e);return o[0]=s.scale[0],o[1]=0,o[2]=s.offset[0]*r,o[3]=0,o[4]=0,o[5]=s.scale[1],o[6]=-s.offset[1]*r,o[7]=0,o[8]=0,o[9]=0,o[10]=n/(i-n)*-r,o[11]=n*i/(i-n),o[12]=0,o[13]=0,o[14]=r,o[15]=0,a.transpose(),a}({upTan:Math.tan(e.upDegrees*r),downTan:Math.tan(e.downDegrees*r),leftTan:Math.tan(e.leftDegrees*r),rightTan:Math.tan(e.rightDegrees*r)},t,i,n)}},pu=function(e,t){this.object=e,this.domElement=void 0!==t?t:document,this.enabled=!0,this.target=new Bt,this.minDistance=0,this.maxDistance=1/0,this.minZoom=0,this.maxZoom=1/0,this.minPolarAngle=0,this.maxPolarAngle=Math.PI,this.minAzimuthAngle=-1/0,this.maxAzimuthAngle=1/0,this.enableDamping=!1,this.dampingFactor=.25,this.enableZoom=!0,this.zoomSpeed=1,this.enableRotate=!0,this.rotateSpeed=1,this.enablePan=!0,this.panSpeed=1,this.screenSpacePanning=!1,this.keyPanSpeed=7,this.autoRotate=!1,this.autoRotateSpeed=2,this.enableKeys=!0,this.keys={LEFT:37,UP:38,RIGHT:39,BOTTOM:40},this.mouseButtons={ORBIT:v,ZOOM:A,PAN:w},this.target0=this.target.clone(),this.position0=this.object.position.clone(),this.zoom0=this.object.zoom,this.getPolarAngle=function(){return u.phi},this.getAzimuthalAngle=function(){return u.theta},this.saveState=function(){i.target0.copy(i.target),i.position0.copy(i.object.position),i.zoom0=i.object.zoom},this.reset=function(){i.target.copy(i.target0),i.object.position.copy(i.position0),i.object.zoom=i.zoom0,i.object.updateProjectionMatrix(),i.dispatchEvent(n),i.update(),s=o.NONE},this.update=function(){var t=new Bt,r=(new Pt).setFromUnitVectors(e.up,new Bt(0,1,0)),a=r.clone().inverse(),f=new Bt,g=new Pt;return function(){var e=i.object.position;return t.copy(e).sub(i.target),t.applyQuaternion(r),u.setFromVector3(t),i.autoRotate&&s===o.NONE&&i.rotateLeft(2*Math.PI/60/60*i.autoRotateSpeed),u.theta+=h.theta,u.phi+=h.phi,u.theta=Math.max(i.minAzimuthAngle,Math.min(i.maxAzimuthAngle,u.theta)),u.phi=Math.max(i.minPolarAngle,Math.min(i.maxPolarAngle,u.phi)),u.makeSafe(),u.radius*=l,u.radius=Math.max(i.minDistance,Math.min(i.maxDistance,u.radius)),i.target.add(d),t.setFromSpherical(u),t.applyQuaternion(a),e.copy(i.target).add(t),i.object.lookAt(i.target),!0===i.enableDamping?(h.theta*=1-i.dampingFactor,h.phi*=1-i.dampingFactor,d.multiplyScalar(1-i.dampingFactor)):(h.set(0,0,0),d.set(0,0,0)),l=1,!!(p||f.distanceToSquared(i.object.position)>c||8*(1-g.dot(i.object.quaternion))>c)&&(i.dispatchEvent(n),f.copy(i.object.position),g.copy(i.object.quaternion),p=!1,!0)}}(),this.dispose=function(){i.domElement.removeEventListener("contextmenu",k,!1),i.domElement.removeEventListener("mousedown",j,!1),i.domElement.removeEventListener("wheel",z,!1),i.domElement.removeEventListener("touchstart",U,!1),i.domElement.removeEventListener("touchend",B,!1),i.domElement.removeEventListener("touchmove",P,!1),document.removeEventListener("mousemove",C,!1),document.removeEventListener("mouseup",O,!1),window.removeEventListener("keydown",R,!1)};var i=this,n={type:"change"},r={type:"start"},a={type:"end"},o={NONE:-1,ROTATE:0,DOLLY:1,PAN:2,TOUCH_ROTATE:3,TOUCH_DOLLY_PAN:4},s=o.NONE,c=1e-6,u=new Vc,h=new Vc,l=1,d=new Bt,p=!1,f=new Rt,g=new Rt,m=new Rt,M=new Rt,y=new Rt,x=new Rt,E=new Rt,T=new Rt,N=new Rt;function D(){return Math.pow(.95,i.zoomSpeed)}i.rotateLeft=function(e){h.theta-=e},i.rotateUp=function(e){h.phi-=e};var L=function(){var e=new Bt;return function(t,i){e.setFromMatrixColumn(i,0),e.multiplyScalar(-t),d.add(e)}}(),b=function(){var e=new Bt;return function(t,n){!0===i.screenSpacePanning?e.setFromMatrixColumn(n,1):(e.setFromMatrixColumn(n,0),e.crossVectors(i.object.up,e)),e.multiplyScalar(t),d.add(e)}}(),I=function(){var e=new Bt;return function(t,n){var r=i.domElement===document?i.domElement.body:i.domElement;if(i.object.isPerspectiveCamera){var a=i.object.position;e.copy(a).sub(i.target);var o=e.length();o*=Math.tan(i.object.fov/2*Math.PI/180),L(2*t*o/r.clientHeight,i.object.matrix),b(2*n*o/r.clientHeight,i.object.matrix)}else i.object.isOrthographicCamera?(L(t*(i.object.right-i.object.left)/i.object.zoom/r.clientWidth,i.object.matrix),b(n*(i.object.top-i.object.bottom)/i.object.zoom/r.clientHeight,i.object.matrix)):(console.warn("WARNING: OrbitControls.js encountered an unknown camera type - pan disabled."),i.enablePan=!1)}}();function _(e){i.object.isPerspectiveCamera?l/=e:i.object.isOrthographicCamera?(i.object.zoom=Math.max(i.minZoom,Math.min(i.maxZoom,i.object.zoom*e)),i.object.updateProjectionMatrix(),p=!0):(console.warn("WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled."),i.enableZoom=!1)}function S(e){i.object.isPerspectiveCamera?l*=e:i.object.isOrthographicCamera?(i.object.zoom=Math.max(i.minZoom,Math.min(i.maxZoom,i.object.zoom/e)),i.object.updateProjectionMatrix(),p=!0):(console.warn("WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled."),i.enableZoom=!1)}function j(e){if(!1!==i.enabled){switch(e.preventDefault(),e.button){case i.mouseButtons.ORBIT:if(!1===i.enableRotate)return;!function(e){f.set(e.clientX,e.clientY)}(e),s=o.ROTATE;break;case i.mouseButtons.ZOOM:if(!1===i.enableZoom)return;!function(e){E.set(e.clientX,e.clientY)}(e),s=o.DOLLY;break;case i.mouseButtons.PAN:if(!1===i.enablePan)return;!function(e){M.set(e.clientX,e.clientY)}(e),s=o.PAN}s!==o.NONE&&(document.addEventListener("mousemove",C,!1),document.addEventListener("mouseup",O,!1),i.dispatchEvent(r))}}function C(e){if(!1!==i.enabled)switch(e.preventDefault(),s){case o.ROTATE:if(!1===i.enableRotate)return;!function(e){g.set(e.clientX,e.clientY),m.subVectors(g,f).multiplyScalar(i.rotateSpeed);var t=i.domElement===document?i.domElement.body:i.domElement;i.rotateLeft(2*Math.PI*m.x/t.clientHeight),i.rotateUp(2*Math.PI*m.y/t.clientHeight),f.copy(g),i.update()}(e);break;case o.DOLLY:if(!1===i.enableZoom)return;!function(e){T.set(e.clientX,e.clientY),N.subVectors(T,E),N.y>0?_(D()):N.y<0&&S(D()),E.copy(T),i.update()}(e);break;case o.PAN:if(!1===i.enablePan)return;!function(e){y.set(e.clientX,e.clientY),x.subVectors(y,M).multiplyScalar(i.panSpeed),I(x.x,x.y),M.copy(y),i.update()}(e)}}function O(e){!1!==i.enabled&&(document.removeEventListener("mousemove",C,!1),document.removeEventListener("mouseup",O,!1),i.dispatchEvent(a),s=o.NONE)}function z(e){!1===i.enabled||!1===i.enableZoom||s!==o.NONE&&s!==o.ROTATE||(e.preventDefault(),e.stopPropagation(),i.dispatchEvent(r),function(e){e.deltaY<0?S(D()):e.deltaY>0&&_(D()),i.update()}(e),i.dispatchEvent(a))}function R(e){!1!==i.enabled&&!1!==i.enableKeys&&!1!==i.enablePan&&function(e){switch(e.keyCode){case i.keys.UP:I(0,i.keyPanSpeed),i.update();break;case i.keys.BOTTOM:I(0,-i.keyPanSpeed),i.update();break;case i.keys.LEFT:I(i.keyPanSpeed,0),i.update();break;case i.keys.RIGHT:I(-i.keyPanSpeed,0),i.update()}}(e)}function U(e){if(!1!==i.enabled){switch(e.preventDefault(),e.touches.length){case 1:if(!1===i.enableRotate)return;!function(e){f.set(e.touches[0].pageX,e.touches[0].pageY)}(e),s=o.TOUCH_ROTATE;break;case 2:if(!1===i.enableZoom&&!1===i.enablePan)return;!function(e){if(i.enableZoom){var t=e.touches[0].pageX-e.touches[1].pageX,n=e.touches[0].pageY-e.touches[1].pageY,r=Math.sqrt(t*t+n*n);E.set(0,r)}if(i.enablePan){var a=.5*(e.touches[0].pageX+e.touches[1].pageX),o=.5*(e.touches[0].pageY+e.touches[1].pageY);M.set(a,o)}}(e),s=o.TOUCH_DOLLY_PAN;break;default:s=o.NONE}s!==o.NONE&&i.dispatchEvent(r)}}function P(e){if(!1!==i.enabled)switch(e.preventDefault(),e.stopPropagation(),e.touches.length){case 1:if(!1===i.enableRotate)return;if(s!==o.TOUCH_ROTATE)return;!function(e){g.set(e.touches[0].pageX,e.touches[0].pageY),m.subVectors(g,f).multiplyScalar(i.rotateSpeed);var t=i.domElement===document?i.domElement.body:i.domElement;i.rotateLeft(2*Math.PI*m.x/t.clientHeight),i.rotateUp(2*Math.PI*m.y/t.clientHeight),f.copy(g),i.update()}(e);break;case 2:if(!1===i.enableZoom&&!1===i.enablePan)return;if(s!==o.TOUCH_DOLLY_PAN)return;!function(e){if(i.enableZoom){var t=e.touches[0].pageX-e.touches[1].pageX,n=e.touches[0].pageY-e.touches[1].pageY,r=Math.sqrt(t*t+n*n);T.set(0,r),N.set(0,Math.pow(T.y/E.y,i.zoomSpeed)),_(N.y),E.copy(T)}if(i.enablePan){var a=.5*(e.touches[0].pageX+e.touches[1].pageX),o=.5*(e.touches[0].pageY+e.touches[1].pageY);y.set(a,o),x.subVectors(y,M).multiplyScalar(i.panSpeed),I(x.x,x.y),M.copy(y)}i.update()}(e);break;default:s=o.NONE}}function B(e){!1!==i.enabled&&(i.dispatchEvent(a),s=o.NONE)}function k(e){!1!==i.enabled&&e.preventDefault()}i.domElement.addEventListener("mousedown",j,!1),i.domElement.addEventListener("wheel",z,!1),i.domElement.addEventListener("touchstart",U,!1),i.domElement.addEventListener("touchend",B,!1),i.domElement.addEventListener("touchmove",P,!1),window.addEventListener("keydown",R,!1),this.update()};pu.prototype=Object.create(u.prototype),pu.prototype.constructor=pu,Object.defineProperties(pu.prototype,{center:{get:function(){return console.warn("OrbitControls: .center has been renamed to .target"),this.target}},noZoom:{get:function(){return console.warn("OrbitControls: .noZoom has been deprecated. Use .enableZoom instead."),!this.enableZoom},set:function(e){console.warn("OrbitControls: .noZoom has been deprecated. Use .enableZoom instead."),this.enableZoom=!e}},noRotate:{get:function(){return console.warn("OrbitControls: .noRotate has been deprecated. Use .enableRotate instead."),!this.enableRotate},set:function(e){console.warn("OrbitControls: .noRotate has been deprecated. Use .enableRotate instead."),this.enableRotate=!e}},noPan:{get:function(){return console.warn("OrbitControls: .noPan has been deprecated. Use .enablePan instead."),!this.enablePan},set:function(e){console.warn("OrbitControls: .noPan has been deprecated. Use .enablePan instead."),this.enablePan=!e}},noKeys:{get:function(){return console.warn("OrbitControls: .noKeys has been deprecated. Use .enableKeys instead."),!this.enableKeys},set:function(e){console.warn("OrbitControls: .noKeys has been deprecated. Use .enableKeys instead."),this.enableKeys=!e}},staticMoving:{get:function(){return console.warn("OrbitControls: .staticMoving has been deprecated. Use .enableDamping instead."),!this.enableDamping},set:function(e){console.warn("OrbitControls: .staticMoving has been deprecated. Use .enableDamping instead."),this.enableDamping=!e}},dynamicDampingFactor:{get:function(){return console.warn("OrbitControls: .dynamicDampingFactor has been renamed. Use .dampingFactor instead."),this.dampingFactor},set:function(e){console.warn("OrbitControls: .dynamicDampingFactor has been renamed. Use .dampingFactor instead."),this.dampingFactor=e}}});var fu=function(e){var t=this;this.object=e,this.object.rotation.reorder("YXZ"),this.enabled=!0,this.deviceOrientation={},this.screenOrientation=0,this.alphaOffset=0;var i=function(e){t.deviceOrientation=e},n=function(){t.screenOrientation=window.orientation||0},r=function(){var e=new Bt(0,0,1),t=new di,i=new Pt,n=new Pt(-Math.sqrt(.5),0,0,Math.sqrt(.5));return function(r,a,o,s,c){t.set(o,a,-s,"YXZ"),r.setFromEuler(t),r.multiply(n),r.multiply(i.setFromAxisAngle(e,-c))}}();this.connect=function(){n(),window.addEventListener("orientationchange",n,!1),window.addEventListener("deviceorientation",i,!1),t.enabled=!0},this.disconnect=function(){window.removeEventListener("orientationchange",n,!1),window.removeEventListener("deviceorientation",i,!1),t.enabled=!1},this.update=function(){if(!1!==t.enabled){var e=t.deviceOrientation;if(e){var i=e.alpha?zt.degToRad(e.alpha)+t.alphaOffset:0,n=e.beta?zt.degToRad(e.beta):0,a=e.gamma?zt.degToRad(e.gamma):0,o=t.screenOrientation?zt.degToRad(t.screenOrientation):0;r(t.object.quaternion,i,n,a,o)}}},this.dispose=function(){t.disconnect()},this.connect()};var gu,mu=function(){function e(e){this.object=e.camera,this.domElement=e.canvas,this.orbit=new pu(this.object,this.domElement),this.speed=.5,this.orbit.target.set(0,0,-1),this.orbit.enableZoom=!1,this.orbit.enablePan=!1,this.orbit.rotateSpeed=-this.speed,e.orientation&&(this.orientation=new fu(this.object)),e.halfView&&(this.orbit.minAzimuthAngle=-Math.PI/4,this.orbit.maxAzimuthAngle=Math.PI/4)}var t=e.prototype;return t.update=function(){if(this.orientation){this.orientation.update();var e=this.orientation.object.quaternion,t=function(e,t,i,n){var r=e*t+i*n;if(r>.499){var a=2*Math.atan2(e,n);return new Bt(Math.PI/2,0,a)}if(r<-.499){var o=-2*Math.atan2(e,n);return new Bt(-Math.PI/2,0,o)}var s=e*e,c=t*t,u=i*i,h=Math.atan2(2*t*n-2*e*i,1-2*c-2*u);return new Bt(Math.asin(2*r),Math.atan2(2*e*n-2*t*i,1-2*s-2*u),h)}(e.x,e.y,e.z,e.w);void 0===this.lastAngle_&&(this.lastAngle_=t),this.orbit.rotateLeft((this.lastAngle_.z-t.z)*(1+this.speed)),this.orbit.rotateUp((this.lastAngle_.y-t.y)*(1+this.speed)),this.lastAngle_=t}this.orbit.update()},t.dispose=function(){this.orbit.dispose(),this.orientation&&this.orientation.dispose()},e}(),Mu=((gu=t.createElement("video")).crossOrigin="anonymous",gu.hasAttribute("crossorigin")),yu=["360","360_LR","360_TB","360_CUBE","EAC","EAC_LR","NONE","AUTO","Sphere","Cube","equirectangular","180"],vu=function(e){if(e){if(e=e.toString().trim(),/sphere/i.test(e))return"360";if(/cube/i.test(e))return"360_CUBE";if(/equirectangular/i.test(e))return"360";for(var t=0;thttp://webvr.info for more info."},"web-vr-not-supported":{headline:"360 not supported on this device",type:"360_NOT_SUPPORTED",message:"Your browser does not support 360. See http://webvr.info for assistance."},"web-vr-hls-cors-not-supported":{headline:"360 HLS video not supported on this device",type:"360_NOT_SUPPORTED",message:"Your browser/device does not support HLS 360 video. See http://webvr.info for assistance."}},Du=i.getPlugin("plugin"),Lu=i.getComponent("Component"),bu=function(a){function o(e,t){var r,o=i.mergeOptions(Tu,t);return(r=a.call(this,e,o)||this).options_=o,r.player_=e,r.bigPlayButtonIndex_=e.children().indexOf(e.getChild("BigPlayButton"))||0,r.videojsErrorsSupport_=!!i.errors,r.videojsErrorsSupport_&&e.errors({errors:Nu}),i.browser.IE_VERSION||!Mu?(r.player_.on("loadstart",function(){r.triggerError_({code:"web-vr-not-supported",dismiss:!1})}),n(r)):(r.polyfill_=new c({ROTATE_INSTRUCTIONS_DISABLED:!0}),r.polyfill_=new c,r.handleVrDisplayActivate_=i.bind(n(r),r.handleVrDisplayActivate_),r.handleVrDisplayDeactivate_=i.bind(n(r),r.handleVrDisplayDeactivate_),r.handleResize_=i.bind(n(r),r.handleResize_),r.animate_=i.bind(n(r),r.animate_),r.setProjection(r.options_.projection),r.on(e,"adstart",function(){return e.setTimeout(function(){e.ads&&e.ads.videoElementRecycled()?(r.log("video element recycled for this ad, reseting"),r.reset(),r.one(e,"playing",r.init)):r.log("video element not recycled for this ad, no need to reset")})},1),r.on(e,"loadedmetadata",r.init),r)}r(o,a);var s=o.prototype;return s.changeProjection_=function(e){var t=this;(e=vu(e))||(e="NONE");var i=0,n=0,r=0;if(this.scene&&this.scene.remove(this.movieScreen),"AUTO"===e){if(this.player_.mediainfo&&this.player_.mediainfo.projection&&"AUTO"!==this.player_.mediainfo.projection){var a=vu(this.player_.mediainfo.projection);return this.changeProjection_(a)}return this.changeProjection_("NONE")}if("360"===e)this.movieGeometry=new po(256,32,32),this.movieMaterial=new en({map:this.videoTexture,overdraw:!0,side:b}),this.movieScreen=new on(this.movieGeometry,this.movieMaterial),this.movieScreen.position.set(i,n,r),this.movieScreen.scale.x=-1,this.movieScreen.quaternion.setFromAxisAngle({x:0,y:1,z:0},-Math.PI/2),this.scene.add(this.movieScreen);else if("360_LR"===e||"360_TB"===e){for(var o=new lo(256,32,32),s=o.faceVertexUvs[0],c=0;c=y.length)break;w=y[A++]}else{if((A=y.next()).done)break;w=A.value}var x=w;x.yM&&(M=x.y)}var E=f,T=Array.isArray(E),N=0;for(E=T?E:E[Symbol.iterator]();;){var D;if(T){if(N>=E.length)break;D=E[N++]}else{if((N=E.next()).done)break;D=N.value}var L=D;Math.abs(L.y-m)0&&(t.log("Displays found",e),t.vrDisplay=e[0],t.vrDisplay.isPolyfilled||(t.log("Real HMD found using VRControls",t.vrDisplay),t.addCardboardButton_(),t.controls3d=new lu(t.camera))),!t.controls3d){t.log("no HMD found Using Orbit & Orientation Controls");var n={camera:t.camera,canvas:t.renderedCanvas,halfView:"180"===t.currentProjection_,orientation:i.browser.IS_IOS||i.browser.IS_ANDROID||!1};!1===t.options_.motionControls&&(n.orientation=!1),t.controls3d=new mu(n),t.canvasPlayerControls=new Au(t.player_,t.renderedCanvas)}t.animationFrameId_=t.requestAnimationFrame(t.animate_)})):e.navigator.getVRDevices?this.triggerError_({code:"web-vr-out-of-date",dismiss:!1}):this.triggerError_({code:"web-vr-not-supported",dismiss:!1}),this.options_.omnitone){var o=dc.getContext();this.omniController=new wu(o,this.options_.omnitone,this.getVideoEl_(),this.options_.omnitoneOptions),this.omniController.one("audiocontext-suspended",function(){t.player.pause(),t.player.one("playing",function(){o.resume()})})}this.on(this.player_,"fullscreenchange",this.handleResize_),e.addEventListener("vrdisplaypresentchange",this.handleResize_,!0),e.addEventListener("resize",this.handleResize_,!0),e.addEventListener("vrdisplayactivate",this.handleVrDisplayActivate_,!0),e.addEventListener("vrdisplaydeactivate",this.handleVrDisplayDeactivate_,!0),this.initialized_=!0,this.trigger("initialized")},s.addCardboardButton_=function(){this.player_.controlBar.getChild("CardboardButton")||this.player_.controlBar.addChild("CardboardButton",{})},s.getVideoEl_=function(){return this.player_.el().getElementsByTagName("video")[0]},s.reset=function(){if(this.initialized_){this.omniController&&(this.omniController.off("audiocontext-suspended"),this.omniController.dispose(),this.omniController=void 0),this.controls3d&&(this.controls3d.dispose(),this.controls3d=null),this.canvasPlayerControls&&(this.canvasPlayerControls.dispose(),this.canvasPlayerControls=null),this.effect&&(this.effect.dispose(),this.effect=null),e.removeEventListener("resize",this.handleResize_,!0),e.removeEventListener("vrdisplaypresentchange",this.handleResize_,!0),e.removeEventListener("vrdisplayactivate",this.handleVrDisplayActivate_,!0),e.removeEventListener("vrdisplaydeactivate",this.handleVrDisplayDeactivate_,!0),this.player_.getChild("BigPlayButton")||this.player_.addChild("BigPlayButton",{},this.bigPlayButtonIndex_),this.player_.getChild("BigVrPlayButton")&&this.player_.removeChild("BigVrPlayButton"),this.player_.getChild("CardboardButton")&&this.player_.controlBar.removeChild("CardboardButton"),i.browser.IS_IOS&&this.player_.controlBar.fullscreenToggle.show();var t=this.getVideoEl_().style;t.zIndex="",t.opacity="",this.currentProjection_=this.defaultProjection_,this.iosRevertTouchToClick_&&this.iosRevertTouchToClick_(),this.renderedCanvas&&this.renderedCanvas.parentNode.removeChild(this.renderedCanvas),this.animationFrameId_&&this.cancelAnimationFrame(this.animationFrameId_),this.initialized_=!1}},s.dispose=function(){a.prototype.dispose.call(this),this.reset()},s.polyfillVersion=function(){return c.version},o}(Du);return bu.prototype.setTimeout=Lu.prototype.setTimeout,bu.prototype.clearTimeout=Lu.prototype.clearTimeout,bu.VERSION="1.7.1",i.registerPlugin("vr",bu),bu}); diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index d3445ec3..1490916a 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -27,5 +27,5 @@ <% if params.vr_mode %> - + <% end %> diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 9da14edc..9f5bcbdd 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -235,7 +235,7 @@ - videojs-vr.js + videojs-vr.js -- cgit v1.2.3 From 43bd331e48ad1a19cd3c7a6d5beb72e3127c5edc Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 1 Apr 2021 02:36:43 +0000 Subject: Multiple youtube_api.cr helper fixes Add documentation Bump web client version string Add charset=UTF-8 to the 'content-type' header Parse JSON and return it as a Hash Handle API error messages --- src/invidious/channels.cr | 40 ++++++++---------------------------- src/invidious/helpers/youtube_api.cr | 29 ++++++++++++++++++++++---- src/invidious/playlists.cr | 2 +- src/invidious/search.cr | 3 +-- 4 files changed, 36 insertions(+), 38 deletions(-) (limited to 'src') diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr index 3109b508..bbef3d4f 100644 --- a/src/invidious/channels.cr +++ b/src/invidious/channels.cr @@ -229,22 +229,8 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) page = 1 LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") - response_body = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - - videos = [] of SearchVideo - begin - initial_data = JSON.parse(response_body) - raise InfoException.new("Could not extract channel JSON") if !initial_data - - LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel videos page initial_data") - videos = extract_videos(initial_data.as_h, author, ucid) - rescue ex - if response_body.includes?("To continue with your YouTube experience, please fill out the form below.") || - response_body.includes?("https://www.google.com/sorry/index") - raise InfoException.new("Could not extract channel info. Instance is likely blocked.") - end - raise ex - end + initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) + videos = extract_videos(initial_data, author, ucid) LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") rss.xpath_nodes("//feed/entry").each do |entry| @@ -304,10 +290,8 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) ids = [] of String loop do - response_body = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - initial_data = JSON.parse(response_body) - raise InfoException.new("Could not extract channel JSON") if !initial_data - videos = extract_videos(initial_data.as_h, author, ucid) + initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) + videos = extract_videos(initial_data, author, ucid) count = videos.size videos = videos.map { |video| ChannelVideo.new({ @@ -358,8 +342,7 @@ end def fetch_channel_playlists(ucid, author, continuation, sort_by) if continuation response_json = request_youtube_api_browse(continuation) - result = JSON.parse(response_json) - continuationItems = result["onResponseReceivedActions"]? + continuationItems = response_json["onResponseReceivedActions"]? .try &.[0]["appendContinuationItemsAction"]["continuationItems"] return [] of SearchItem, nil if !continuationItems @@ -964,21 +947,16 @@ def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") videos = [] of SearchVideo 2.times do |i| - response_json = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) - initial_data = JSON.parse(response_json) - break if !initial_data - videos.concat extract_videos(initial_data.as_h, author, ucid) + initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) + videos.concat extract_videos(initial_data, author, ucid) end return videos.size, videos end def get_latest_videos(ucid) - response_json = get_channel_videos_response(ucid) - initial_data = JSON.parse(response_json) - return [] of SearchVideo if !initial_data + initial_data = get_channel_videos_response(ucid) author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s - items = extract_videos(initial_data.as_h, author, ucid) - return items + return extract_videos(initial_data, author, ucid) end diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr index 30413532..84e0c38f 100644 --- a/src/invidious/helpers/youtube_api.cr +++ b/src/invidious/helpers/youtube_api.cr @@ -4,8 +4,18 @@ # Hard-coded constants required by the API HARDCODED_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" -HARDCODED_CLIENT_VERS = "2.20210318.08.00" +HARDCODED_CLIENT_VERS = "2.20210330.08.00" +#################################################################### +# request_youtube_api_browse(continuation) +# +# Requests the youtubei/vi/browse endpoint with the required headers +# to get JSON in en-US (english US). +# +# The requested data is a continuation token (ctoken). Depending on +# this token's contents, the returned data can be comments, playlist +# videos, search results, channel community tab, ... +# def request_youtube_api_browse(continuation) # JSON Request data, required by the API data = { @@ -20,12 +30,23 @@ def request_youtube_api_browse(continuation) "continuation": continuation, } - # Send the POST request and return result + # Send the POST request and parse result response = YT_POOL.client &.post( "/youtubei/v1/browse?key=#{HARDCODED_API_KEY}", - headers: HTTP::Headers{"content-type" => "application/json"}, + headers: HTTP::Headers{"content-type" => "application/json; charset=UTF-8"}, body: data.to_json ) - return response.body + initial_data = JSON.parse(response.body).as_h + + # Error handling + if initial_data.has_key?("error") + code = initial_data["error"]["code"] + message = initial_data["error"]["message"].to_s.sub(/(\\n)+\^$/, "") + + raise InfoException.new("Could not extract JSON. Youtube API returned \ + error #{code} with message:
\"#{message}\"") + end + + return initial_data end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 073a9986..150f1c15 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -451,7 +451,7 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) offset = (offset / 100).to_i64 * 100_i64 ctoken = produce_playlist_continuation(playlist.id, offset) - initial_data = JSON.parse(request_youtube_api_browse(ctoken)).as_h + initial_data = request_youtube_api_browse(ctoken) else response = YT_POOL.client &.get("/playlist?list=#{playlist.id}&gl=US&hl=en") initial_data = extract_initial_data(response.body) diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 4b216613..7c9c389e 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -246,8 +246,7 @@ def channel_search(query, page, channel) continuation = produce_channel_search_continuation(ucid, query, page) response_json = request_youtube_api_browse(continuation) - result = JSON.parse(response_json) - continuationItems = result["onResponseReceivedActions"]? + continuationItems = response_json["onResponseReceivedActions"]? .try &.[0]["appendContinuationItemsAction"]["continuationItems"] return 0, [] of SearchItem if !continuationItems -- cgit v1.2.3 From 26a7e1b049bde355b5ac05d1923b92c6f4a20179 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 7 Apr 2021 03:15:02 +0200 Subject: Use '/youtubei/v1/search' endpoint for search queries --- src/invidious/helpers/youtube_api.cr | 42 +++++++++++++++++++++++++++++++++++- src/invidious/search.cr | 7 +----- 2 files changed, 42 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr index 84e0c38f..dc3a7eb5 100644 --- a/src/invidious/helpers/youtube_api.cr +++ b/src/invidious/helpers/youtube_api.cr @@ -30,9 +30,49 @@ def request_youtube_api_browse(continuation) "continuation": continuation, } + return _youtube_api_post_json("/youtubei/v1/browse", data) +end + +#################################################################### +# request_youtube_api_search(search_query, params, region) +# +# Requests the youtubei/vi/search endpoint with the required headers +# to get JSON in en-US (english US). +# +# The requested data is a search string, with some additional +# paramters, formatted as a base64 string. +# +def request_youtube_api_search(search_query : String, params : String, region = nil) + # JSON Request data, required by the API + data = { + "query": URI.encode_www_form(search_query), + "context": { + "client": { + "hl": "en", + "gl": region || "US", # Can't be empty! + "clientName": "WEB", + "clientVersion": HARDCODED_CLIENT_VERS, + }, + }, + "params": params, + } + + return _youtube_api_post_json("/youtubei/v1/search", data) +end + +#################################################################### +# _youtube_api_post_json(endpoint, data) +# +# Internal function that does the actual request to youtube servers +# and handles errors. +# +# The requested data is an endpoint (URL without the domain part) +# and the data as a Hash object. +# +def _youtube_api_post_json(endpoint, data) # Send the POST request and parse result response = YT_POOL.client &.post( - "/youtubei/v1/browse?key=#{HARDCODED_API_KEY}", + "#{endpoint}?key=#{HARDCODED_API_KEY}", headers: HTTP::Headers{"content-type" => "application/json; charset=UTF-8"}, body: data.to_json ) diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 7c9c389e..662173a0 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -263,14 +263,9 @@ end def search(query, search_params = produce_search_params(content_type: "all"), region = nil) return 0, [] of SearchItem if query.empty? - body = YT_POOL.client(region, &.get("/results?search_query=#{URI.encode_www_form(query)}&sp=#{search_params}&hl=en").body) - return 0, [] of SearchItem if body.empty? - - initial_data = extract_initial_data(body) + initial_data = request_youtube_api_search(query, search_params, region) items = extract_items(initial_data) - # initial_data["estimatedResults"]?.try &.as_s.to_i64 - return items.size, items end -- cgit v1.2.3 From 344ccf3b03f640fa65504d45a1fa8df87e1c744c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 13 Apr 2021 19:33:37 +0200 Subject: Use '/youtubei/v1/browse' endpoint for playlists --- src/invidious/helpers/youtube_api.cr | 36 ++++++++++++++++++++++++++++++++---- src/invidious/playlists.cr | 20 +++----------------- 2 files changed, 35 insertions(+), 21 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr index dc3a7eb5..1b8f6dae 100644 --- a/src/invidious/helpers/youtube_api.cr +++ b/src/invidious/helpers/youtube_api.cr @@ -8,15 +8,20 @@ HARDCODED_CLIENT_VERS = "2.20210330.08.00" #################################################################### # request_youtube_api_browse(continuation) +# request_youtube_api_browse(browse_id, params) # # Requests the youtubei/vi/browse endpoint with the required headers # to get JSON in en-US (english US). # -# The requested data is a continuation token (ctoken). Depending on -# this token's contents, the returned data can be comments, playlist -# videos, search results, channel community tab, ... +# The requested data can either be: # -def request_youtube_api_browse(continuation) +# - A continuation token (ctoken). Depending on this token's +# contents, the returned data can be comments, playlist videos, +# search results, channel community tab, ... +# +# - A playlist ID (parameters MUST be an empty string) +# +def request_youtube_api_browse(continuation : String) # JSON Request data, required by the API data = { "context": { @@ -33,6 +38,29 @@ def request_youtube_api_browse(continuation) return _youtube_api_post_json("/youtubei/v1/browse", data) end +def request_youtube_api_browse(browse_id : String, params : String) + # JSON Request data, required by the API + data = { + "browseId" => browse_id, + "context" => { + "client" => { + "hl" => "en", + "gl" => "US", + "clientName" => "WEB", + "clientVersion" => HARDCODED_CLIENT_VERS, + }, + }, + } + + # Append the additionnal parameters if those were provided + # (this is required for channel info, playlist and community, e.g) + if params != "" + data["params"] = params + end + + return _youtube_api_post_json("/youtubei/v1/browse", data) +end + #################################################################### # request_youtube_api_search(search_query, params, region) # diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 150f1c15..fe7f82f3 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -361,16 +361,7 @@ def fetch_playlist(plid, locale) plid = "UU#{plid.lchop("UC")}" end - response = YT_POOL.client &.get("/playlist?list=#{plid}&hl=en") - if response.status_code != 200 - if response.headers["location"]?.try &.includes? "/sorry/index" - raise InfoException.new("Could not extract playlist info. Instance is likely blocked.") - else - raise InfoException.new("Not a playlist.") - end - end - - initial_data = extract_initial_data(response.body) + initial_data = request_youtube_api_browse("VL" + plid, params: "") playlist_sidebar_renderer = initial_data["sidebar"]?.try &.["playlistSidebarRenderer"]?.try &.["items"]? raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer @@ -453,15 +444,10 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) ctoken = produce_playlist_continuation(playlist.id, offset) initial_data = request_youtube_api_browse(ctoken) else - response = YT_POOL.client &.get("/playlist?list=#{playlist.id}&gl=US&hl=en") - initial_data = extract_initial_data(response.body) + initial_data = request_youtube_api_browse("VL" + playlist.id, params: "") end - if initial_data - return extract_playlist_videos(initial_data) - else - return [] of PlaylistVideo - end + return extract_playlist_videos(initial_data) end end -- cgit v1.2.3 From cbabf0ae7e5d3e3ebe73f46832bd751648263467 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 24 May 2021 13:33:46 +0200 Subject: Craft the "context" data in a dedicated function As the amount of API endpoint function grow, this will prevent ugly code copy/pasta --- src/invidious/helpers/youtube_api.cr | 50 +++++++++++++++++------------------- 1 file changed, 23 insertions(+), 27 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr index 1b8f6dae..bd120a4c 100644 --- a/src/invidious/helpers/youtube_api.cr +++ b/src/invidious/helpers/youtube_api.cr @@ -6,6 +6,23 @@ HARDCODED_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" HARDCODED_CLIENT_VERS = "2.20210330.08.00" +#################################################################### +# make_youtube_api_context(region) +# +# Return, as a Hash, the "context" data required to request the +# youtube API endpoints. +# +def make_youtube_api_context(region : String | Nil) : Hash + return { + "client" => { + "hl" => "en", + "gl" => region || "US", # Can't be empty! + "clientName" => "WEB", + "clientVersion" => HARDCODED_CLIENT_VERS, + } + } +end + #################################################################### # request_youtube_api_browse(continuation) # request_youtube_api_browse(browse_id, params) @@ -24,15 +41,8 @@ HARDCODED_CLIENT_VERS = "2.20210330.08.00" def request_youtube_api_browse(continuation : String) # JSON Request data, required by the API data = { - "context": { - "client": { - "hl": "en", - "gl": "US", - "clientName": "WEB", - "clientVersion": HARDCODED_CLIENT_VERS, - }, - }, - "continuation": continuation, + "context" => make_youtube_api_context("US"), + "continuation" => continuation, } return _youtube_api_post_json("/youtubei/v1/browse", data) @@ -42,14 +52,7 @@ def request_youtube_api_browse(browse_id : String, params : String) # JSON Request data, required by the API data = { "browseId" => browse_id, - "context" => { - "client" => { - "hl" => "en", - "gl" => "US", - "clientName" => "WEB", - "clientVersion" => HARDCODED_CLIENT_VERS, - }, - }, + "context" => make_youtube_api_context("US"), } # Append the additionnal parameters if those were provided @@ -73,16 +76,9 @@ end def request_youtube_api_search(search_query : String, params : String, region = nil) # JSON Request data, required by the API data = { - "query": URI.encode_www_form(search_query), - "context": { - "client": { - "hl": "en", - "gl": region || "US", # Can't be empty! - "clientName": "WEB", - "clientVersion": HARDCODED_CLIENT_VERS, - }, - }, - "params": params, + "query" => URI.encode_www_form(search_query), + "context" => make_youtube_api_context(region), + "params" => params, } return _youtube_api_post_json("/youtubei/v1/search", data) -- cgit v1.2.3 From b7fe212a184b5af1ccb9315e106c0ecb2f150590 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 24 May 2021 15:20:26 +0200 Subject: Fix youtube API function's documentation --- src/invidious/helpers/youtube_api.cr | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr index bd120a4c..544e635b 100644 --- a/src/invidious/helpers/youtube_api.cr +++ b/src/invidious/helpers/youtube_api.cr @@ -19,7 +19,7 @@ def make_youtube_api_context(region : String | Nil) : Hash "gl" => region || "US", # Can't be empty! "clientName" => "WEB", "clientVersion" => HARDCODED_CLIENT_VERS, - } + }, } end @@ -27,8 +27,9 @@ end # request_youtube_api_browse(continuation) # request_youtube_api_browse(browse_id, params) # -# Requests the youtubei/vi/browse endpoint with the required headers -# to get JSON in en-US (english US). +# Requests the youtubei/v1/browse endpoint with the required headers +# and POST data in order to get a JSON reply in english US that can +# be easily parsed. # # The requested data can either be: # @@ -67,8 +68,10 @@ end #################################################################### # request_youtube_api_search(search_query, params, region) # -# Requests the youtubei/vi/search endpoint with the required headers -# to get JSON in en-US (english US). +# Requests the youtubei/v1/search endpoint with the required headers +# and POST data in order to get a JSON reply. As the search results +# vary depending on the region, a region code can be specified in +# order to get non-US results. # # The requested data is a search string, with some additional # paramters, formatted as a base64 string. -- cgit v1.2.3 From 445ff856fe73ff3d08205431b5679108adbe17f3 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 24 May 2021 09:16:58 -0700 Subject: Allow user preferences to effect extend_desc --- src/invidious/routes/preferences.cr | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 815c1c70..cfdad443 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -60,6 +60,10 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute volume = env.params.body["volume"]?.try &.as(String).to_i? volume ||= CONFIG.default_user_preferences.volume + extend_desc = env.params.body["extend_desc"]?.try &.as(String) + extend_desc ||= "off" + extend_desc = extend_desc == "on" + vr_mode = env.params.body["vr_mode"]?.try &.as(String) vr_mode ||= "off" vr_mode = vr_mode == "on" @@ -144,6 +148,7 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute unseen_only: unseen_only, video_loop: video_loop, volume: volume, + extend_desc: extend_desc, vr_mode: vr_mode, }.to_json).to_json -- cgit v1.2.3 From ca5d5668d9687ea57d880bcda259a156443c5180 Mon Sep 17 00:00:00 2001 From: Ray Cheung Date: Sat, 29 May 2021 11:42:44 +0800 Subject: Fix storyboard when proxied with an external port Say if it's `http://host:port` internally and proxied to `https://domain:external_port`, the storyboard URL was rendered as `https://domain:port`. --- src/invidious/helpers/utils.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 10d4e6b6..6ce457b9 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -298,7 +298,7 @@ def make_host_url(kemal_config) # Add if non-standard port if port != 80 && port != 443 - port = ":#{kemal_config.port}" + port = ":#{port}" else port = "" end -- cgit v1.2.3 From 1a2ca8634df21f57f56e83b2b4490cb40f26d35c Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Fri, 4 Jun 2021 18:53:24 +0000 Subject: typo in the template file --- src/invidious/views/template.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 5b63bf1f..a13d3928 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -26,7 +26,7 @@
-
+
-- cgit v1.2.3 From b9cd40fe1e095f72440bb64180314b71d4f3f185 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 15 May 2021 22:01:46 -0700 Subject: Add redirect buttons to error template --- locales/en-US.json | 5 ++++- src/invidious/helpers/errors.cr | 37 +++++++++++++++++++++++++++++++++++++ src/invidious/views/error.ecr | 1 + 3 files changed, 42 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 29eae79e..106bdcf2 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -419,5 +419,8 @@ "location": "Location", "hdr": "HDR", "filter": "Filter", - "Current version: ": "Current version: " + "Current version: ": "Current version: ", + "next_steps_error_message": "After which you should try to: ", + "next_steps_error_message_refresh": "Refresh", + "next_steps_error_message_go_to_youtube": "Go to Youtube" } diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 68ced430..714e0670 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -40,6 +40,9 @@ def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSO and include the following text in your message:
#{issue_template}
END_HTML + + next_steps = error_redirect_helper(env, locale) + return templated "error" end @@ -47,6 +50,7 @@ def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSO env.response.content_type = "text/html" env.response.status_code = status_code error_message = translate(locale, message) + next_steps = error_redirect_helper(env, locale) return templated "error" end @@ -103,3 +107,36 @@ end def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) error_json_helper(env, locale, status_code, message, nil) end + +def error_redirect_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil) + request_path = env.request.path + + if request_path.starts_with?("/search") || request_path.starts_with?("/watch") || + request_path.starts_with?("/channel") || request_path.starts_with?("/playlist?list=PL") + + next_steps_text = translate(locale, "next_steps_error_message") + refresh = translate(locale, "next_steps_error_message_refresh") + go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube") + switch_instance = translate(locale, "Switch Invidious Instance") + + + return <<-END_HTML +

#{next_steps_text}

+ + END_HTML + + return next_step_html + else + return "" + end +end \ No newline at end of file diff --git a/src/invidious/views/error.ecr b/src/invidious/views/error.ecr index d0752e5b..04eb74d5 100644 --- a/src/invidious/views/error.ecr +++ b/src/invidious/views/error.ecr @@ -4,4 +4,5 @@
<%= error_message %> + <%= next_steps %>
-- cgit v1.2.3 From 2f54ec4e18d20a0d3a36e8e192389819b57d8dc3 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 15 May 2021 22:36:10 -0700 Subject: Fix locale consistency for err template redirects --- locales/ar.json | 5 +++- locales/bn_BD.json | 5 +++- locales/cs.json | 5 +++- locales/da.json | 5 +++- locales/de.json | 5 +++- locales/el.json | 5 +++- locales/eo.json | 5 +++- locales/es.json | 5 +++- locales/eu.json | 5 +++- locales/fa.json | 5 +++- locales/fi.json | 5 +++- locales/fr.json | 5 +++- locales/he.json | 5 +++- locales/hr.json | 5 +++- locales/hu-HU.json | 5 +++- locales/id.json | 5 +++- locales/is.json | 5 +++- locales/it.json | 5 +++- locales/ja.json | 5 +++- locales/nb-NO.json | 5 +++- locales/nl.json | 5 +++- locales/pl.json | 5 +++- locales/pt-BR.json | 5 +++- locales/pt-PT.json | 5 +++- locales/ro.json | 5 +++- locales/ru.json | 5 +++- locales/si.json | 5 +++- locales/sk.json | 5 +++- locales/sr.json | 5 +++- locales/sr_Cyrl.json | 5 +++- locales/sv-SE.json | 5 +++- locales/tr.json | 5 +++- locales/uk.json | 5 +++- locales/zh-CN.json | 5 +++- locales/zh-TW.json | 5 +++- src/invidious/helpers/errors.cr | 52 ++++++++++++++++++++--------------------- 36 files changed, 165 insertions(+), 62 deletions(-) (limited to 'src') diff --git a/locales/ar.json b/locales/ar.json index b12c4cb8..bc0c6830 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -419,5 +419,8 @@ "location": "الاماكن", "hdr": "وضع التباين العالي", "filter": "معامل الفرز", - "Current version: ": "الإصدار الحالي: " + "Current version: ": "الإصدار الحالي: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } \ No newline at end of file diff --git a/locales/bn_BD.json b/locales/bn_BD.json index 9de526d5..83bd6555 100644 --- a/locales/bn_BD.json +++ b/locales/bn_BD.json @@ -358,5 +358,8 @@ "Videos": "", "Playlists": "", "Community": "", - "Current version: ": "" + "Current version: ": "", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/cs.json b/locales/cs.json index 3d59466a..c8320a07 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -419,5 +419,8 @@ "location": "umístění", "hdr": "HDR", "filter": "filtr", - "Current version: ": "" + "Current version: ": "", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/da.json b/locales/da.json index 03b176f4..d207939c 100644 --- a/locales/da.json +++ b/locales/da.json @@ -419,5 +419,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "" + "Current version: ": "", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/de.json b/locales/de.json index 606fbeb5..b602192b 100644 --- a/locales/de.json +++ b/locales/de.json @@ -419,5 +419,8 @@ "location": "Standort", "hdr": "HDR", "filter": "Filtern", - "Current version: ": "Aktuelle Version: " + "Current version: ": "Aktuelle Version: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/el.json b/locales/el.json index 94611e82..f7588c60 100644 --- a/locales/el.json +++ b/locales/el.json @@ -419,5 +419,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "Τρέχουσα έκδοση: " + "Current version: ": "Τρέχουσα έκδοση: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/eo.json b/locales/eo.json index 69811006..7ac38c35 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -419,5 +419,8 @@ "location": "loko", "hdr": "granddinamikgama", "filter": "filtri", - "Current version: ": "Nuna versio: " + "Current version: ": "Nuna versio: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/es.json b/locales/es.json index 25329d40..e160526e 100644 --- a/locales/es.json +++ b/locales/es.json @@ -419,5 +419,8 @@ "location": "ubicación", "hdr": "hdr", "filter": "filtro", - "Current version: ": "Versión actual: " + "Current version: ": "Versión actual: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/eu.json b/locales/eu.json index 426d721f..34820a50 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -341,5 +341,8 @@ "Videos": "", "Playlists": "", "Community": "", - "Current version: ": "" + "Current version: ": "", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/fa.json b/locales/fa.json index 6b40af62..611cc57d 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -419,5 +419,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "نسخه فعلی: " + "Current version: ": "نسخه فعلی: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/fi.json b/locales/fi.json index 24a4ec36..b446332c 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -419,5 +419,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "Tämänhetkinen versio: " + "Current version: ": "Tämänhetkinen versio: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/fr.json b/locales/fr.json index 75cd2be0..4a685d35 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -419,5 +419,8 @@ "location": "emplacement", "hdr": "HDR", "filter": "filtrer", - "Current version: ": "Version actuelle : " + "Current version: ": "Version actuelle : ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/he.json b/locales/he.json index 368a7e08..d4c70a36 100644 --- a/locales/he.json +++ b/locales/he.json @@ -419,5 +419,8 @@ "location": "מיקום", "hdr": "HDR", "filter": "סינון", - "Current version: ": "הגרסה הנוכחית: " + "Current version: ": "הגרסה הנוכחית: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/hr.json b/locales/hr.json index 5cadab8f..ab9dd54b 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -419,5 +419,8 @@ "location": "lokacija", "hdr": "hdr", "filter": "filtar", - "Current version: ": "Trenutačna verzija: " + "Current version: ": "Trenutačna verzija: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/hu-HU.json b/locales/hu-HU.json index 5c67b4e7..a0c6c17f 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -340,5 +340,8 @@ "Videos": "Videók", "Playlists": "Lejátszási listák", "Community": "Közösség", - "Current version: ": "Jelenlegi verzió: " + "Current version: ": "Jelenlegi verzió: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/id.json b/locales/id.json index 0ed116b8..07d252e6 100644 --- a/locales/id.json +++ b/locales/id.json @@ -419,5 +419,8 @@ "location": "lokasi", "hdr": "hdr", "filter": "saring", - "Current version: ": "Versi saat ini: " + "Current version: ": "Versi saat ini: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/is.json b/locales/is.json index d9ec4105..546d67f8 100644 --- a/locales/is.json +++ b/locales/is.json @@ -419,5 +419,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "Núverandi útgáfa: " + "Current version: ": "Núverandi útgáfa: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/it.json b/locales/it.json index 4105e83f..1a14f172 100644 --- a/locales/it.json +++ b/locales/it.json @@ -419,5 +419,8 @@ "location": "Posizione", "hdr": "HDR", "filter": "Filtra", - "Current version: ": "Versione attuale: " + "Current version: ": "Versione attuale: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/ja.json b/locales/ja.json index 3c6efb77..7a80955d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -419,5 +419,8 @@ "location": "", "hdr": "HDR", "filter": "フィルタ", - "Current version: ": "現在のバージョン: " + "Current version: ": "現在のバージョン: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 4b5eabd0..fec2637f 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -419,5 +419,8 @@ "location": "sted", "hdr": "HDR", "filter": "filtrer", - "Current version: ": "Gjeldende versjon: " + "Current version: ": "Gjeldende versjon: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/nl.json b/locales/nl.json index 16cdb427..30ddd49f 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -419,5 +419,8 @@ "location": "locatie", "hdr": "HDR", "filter": "verfijnen", - "Current version: ": "Huidige versie: " + "Current version: ": "Huidige versie: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } \ No newline at end of file diff --git a/locales/pl.json b/locales/pl.json index f8df837c..12177ce6 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -419,5 +419,8 @@ "location": "", "hdr": "hdr", "filter": "filtr", - "Current version: ": "Aktualna wersja: " + "Current version: ": "Aktualna wersja: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 644aa34d..13f2c65f 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -419,5 +419,8 @@ "location": "localização", "hdr": "hdr", "filter": "filtro", - "Current version: ": "Versão atual: " + "Current version: ": "Versão atual: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/pt-PT.json b/locales/pt-PT.json index c02ca58e..acc08c65 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -419,5 +419,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "Versão atual: " + "Current version: ": "Versão atual: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/ro.json b/locales/ro.json index 5c984ab5..a1cbb270 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -419,5 +419,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "Versiunea actuală: " + "Current version: ": "Versiunea actuală: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } \ No newline at end of file diff --git a/locales/ru.json b/locales/ru.json index cd29f06a..9381c578 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -419,5 +419,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "Текущая версия: " + "Current version: ": "Текущая версия: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } \ No newline at end of file diff --git a/locales/si.json b/locales/si.json index 23cacc1e..cbc9bdde 100644 --- a/locales/si.json +++ b/locales/si.json @@ -419,5 +419,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "" + "Current version: ": "", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/sk.json b/locales/sk.json index ed82fae6..9330232e 100644 --- a/locales/sk.json +++ b/locales/sk.json @@ -358,5 +358,8 @@ "Videos": "", "Playlists": "", "Community": "", - "Current version: ": "" + "Current version: ": "", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/sr.json b/locales/sr.json index 0c64e176..4835e9a3 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -417,5 +417,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "" + "Current version: ": "", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index a76684b4..7ac90fc8 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -341,5 +341,8 @@ "Videos": "", "Playlists": "", "Community": "", - "Current version: ": "Тренутна верзија: " + "Current version: ": "Тренутна верзија: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/sv-SE.json b/locales/sv-SE.json index fe12f8d1..bc148143 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -419,5 +419,8 @@ "location": "plats", "hdr": "hdr", "filter": "", - "Current version: ": "Nuvarande version: " + "Current version: ": "Nuvarande version: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/tr.json b/locales/tr.json index ca36023a..6ada31b5 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -419,5 +419,8 @@ "location": "konum", "hdr": "HDR", "filter": "filtrele", - "Current version: ": "Şu anki sürüm: " + "Current version: ": "Şu anki sürüm: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/uk.json b/locales/uk.json index 9e9239d1..6580bb83 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -419,5 +419,8 @@ "location": "", "hdr": "", "filter": "", - "Current version: ": "Поточна версія: " + "Current version: ": "Поточна версія: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } \ No newline at end of file diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 7f58d67c..fdd87687 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -419,5 +419,8 @@ "location": "位置", "hdr": "hdr", "filter": "过滤器", - "Current version: ": "当前版本: " + "Current version: ": "当前版本: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 61a9f118..3a060e63 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -419,5 +419,8 @@ "location": "位置", "hdr": "HDR", "filter": "篩選條件", - "Current version: ": "目前版本: " + "Current version: ": "目前版本: ", + "next_steps_error_message": "", + "next_steps_error_message_refresh": "", + "next_steps_error_message_go_to_youtube": "" } diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 714e0670..e1d02563 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -108,35 +108,33 @@ def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::A error_json_helper(env, locale, status_code, message, nil) end -def error_redirect_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil) +def error_redirect_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil) request_path = env.request.path - if request_path.starts_with?("/search") || request_path.starts_with?("/watch") || - request_path.starts_with?("/channel") || request_path.starts_with?("/playlist?list=PL") - - next_steps_text = translate(locale, "next_steps_error_message") - refresh = translate(locale, "next_steps_error_message_refresh") - go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube") - switch_instance = translate(locale, "Switch Invidious Instance") - - - return <<-END_HTML -

#{next_steps_text}

- - END_HTML - - return next_step_html + if request_path.starts_with?("/search") || request_path.starts_with?("/watch") || + request_path.starts_with?("/channel") || request_path.starts_with?("/playlist?list=PL") + next_steps_text = translate(locale, "next_steps_error_message") + refresh = translate(locale, "next_steps_error_message_refresh") + go_to_youtube = translate(locale, "next_steps_error_message_go_to_youtube") + switch_instance = translate(locale, "Switch Invidious Instance") + + return <<-END_HTML +

#{next_steps_text}

+ + END_HTML + + return next_step_html else return "" end -end \ No newline at end of file +end -- cgit v1.2.3 From d793d4ba78e331e17690940fcc1a53d65b09ee22 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 15 May 2021 22:45:14 -0700 Subject: Add switch invidious instance btn to all chan tabs --- src/invidious/views/community.ecr | 3 +++ src/invidious/views/playlists.ecr | 5 +++++ 2 files changed, 8 insertions(+) (limited to 'src') diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 3c4eaabb..b0092e5f 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -40,6 +40,9 @@
<%= translate(locale, "View channel on YouTube") %> + <% if !channel.auto_generated %>
<%= translate(locale, "Videos") %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 44bdb94d..975ccd6c 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -42,6 +42,11 @@ + + + -- cgit v1.2.3 From 4a095eb98ef9f3d819d2561877f3ed127a767d71 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 15 May 2021 23:01:21 -0700 Subject: Fix
styling on empty search page --- src/invidious/routes/preferences.cr | 56 ++++++++++++++++++------------------- src/invidious/views/search.ecr | 6 +++- 2 files changed, 33 insertions(+), 29 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 20f68aea..d6002ffd 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -126,35 +126,35 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute # Convert to JSON and back again to take advantage of converters used for compatability preferences = Preferences.from_json({ - annotations: annotations, - annotations_subscribed: annotations_subscribed, - autoplay: autoplay, - captions: captions, - comments: comments, - continue: continue, - continue_autoplay: continue_autoplay, - dark_mode: dark_mode, - latest_only: latest_only, - listen: listen, - local: local, - locale: locale, - max_results: max_results, - notifications_only: notifications_only, - player_style: player_style, - quality: quality, - quality_dash: quality_dash, - default_home: default_home, - feed_menu: feed_menu, + annotations: annotations, + annotations_subscribed: annotations_subscribed, + autoplay: autoplay, + captions: captions, + comments: comments, + continue: continue, + continue_autoplay: continue_autoplay, + dark_mode: dark_mode, + latest_only: latest_only, + listen: listen, + local: local, + locale: locale, + max_results: max_results, + notifications_only: notifications_only, + player_style: player_style, + quality: quality, + quality_dash: quality_dash, + default_home: default_home, + feed_menu: feed_menu, automatic_instance_redirect: automatic_instance_redirect, - related_videos: related_videos, - sort: sort, - speed: speed, - thin_mode: thin_mode, - unseen_only: unseen_only, - video_loop: video_loop, - volume: volume, - extend_desc: extend_desc, - vr_mode: vr_mode, + related_videos: related_videos, + sort: sort, + speed: speed, + thin_mode: thin_mode, + unseen_only: unseen_only, + video_loop: video_loop, + volume: volume, + extend_desc: extend_desc, + vr_mode: vr_mode, }.to_json).to_json if user = env.get? "user" diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index f5e3e39b..15389dce 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -96,7 +96,11 @@ <% end %> -
+<% if count == 0 %> +
+<% else %> +
+<% end %>
-- cgit v1.2.3 From b393e31b766d5fa2156befe61eb2ba023a845208 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 12 Jun 2021 15:35:30 -0700 Subject: Fix inst. fetching for inst w/ disabled stats/err --- src/invidious/helpers/utils.cr | 45 +++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index ec8aee9e..72cb1b1d 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -415,35 +415,52 @@ def fetch_random_instance instance_list = JSON.parse(instance_list.body) filtered_instance_list = [] of String + instance_list.as_a.each do |data| + # TODO Check if current URL is onion instance and use .onion types if so. if data[1]["type"] == "https" + # Instances can have statisitics disabled, which is an requirement of version validation. + begin + data[1]["stats"].as_nil + statistics_disabled = true + next + rescue TypeCastError + statistics_disabled = false + end + + # stats endpoint could also lack the software dict. + next if statistics_disabled || data[1]["stats"]["software"]?.nil? + # Makes sure the instance isn't too outdated. - remote_version = data[1]["stats"]["software"]["version"] - remote_commit_date = remote_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/) - next if !remote_commit_date - remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC) - local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC) + if remote_version = data[1]["stats"]?.try &.["software"]?.try &.["version"] + remote_commit_date = remote_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/) + next if !remote_commit_date + + remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC) + local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC) + + next if (remote_commit_date - local_commit_date).abs.days > 30 - if (remote_commit_date - local_commit_date).abs.days <= 30 # as_nil? doesn't exist. Thus we'll have to handle the error rasied if # as_nil fails. begin broken_health_monitoring = data[1]["monitor"].as_nil - broken_health_monitoring = true if broken_health_monitoring.nil? - rescue TypeCastError - broken_health_monitoring = false - end - - if !broken_health_monitoring health = data[1]["monitor"].as_h["dailyRatios"][0].as_h["ratio"] filtered_instance_list << data[0].as_s if health.to_s.to_f > 90 - else + rescue TypeCastError # We can't check the health if the monitoring is broken. Thus we'll just add it to the list - # and move on + # and move on. Ideally we'll ignore any instance that has broken health monitoring but due to the fact that + # it's an error that often occurs with all the instances at the same time, we have to just skip the check. filtered_instance_list << data[0].as_s end end end end + + # If for some reason no instances managed to get fetched successfully then we'll just redirect to redirect.invidious.io + if filtered_instance_list.size == 0 + return "redirect.invidious.io" + end + return filtered_instance_list.sample(1)[0] end -- cgit v1.2.3 From eb911de92842ecfdb2005822dbaa94d82855f191 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 14 Jun 2021 02:31:51 -0700 Subject: Handle if inst. api is down for rand inst fetch --- src/invidious/helpers/utils.cr | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 72cb1b1d..32359e9a 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -411,12 +411,22 @@ def convert_theme(theme) end def fetch_random_instance - instance_list = HTTP::Client.get "https://api.invidious.io/instances.json" - instance_list = JSON.parse(instance_list.body) + begin + instance_api_client = HTTP::Client.new("api.invidious.io") + + # Timeouts + instance_api_client.connect_timeout = 10.seconds + instance_api_client.dns_timeout = 10.seconds + + instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a + instance_api_client.close + rescue Socket::ConnectError | IO::TimeoutError | JSON::ParseException + instance_list = [] of JSON::Any + end filtered_instance_list = [] of String - instance_list.as_a.each do |data| + instance_list.each do |data| # TODO Check if current URL is onion instance and use .onion types if so. if data[1]["type"] == "https" # Instances can have statisitics disabled, which is an requirement of version validation. -- cgit v1.2.3 From 45e57f1ad30bd2fe731bc70ac099a6ce68255180 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 14 Jun 2021 02:53:53 -0700 Subject: Refactor fetch_random_instance --- src/invidious/helpers/utils.cr | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 32359e9a..c53d5ee0 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -430,16 +430,15 @@ def fetch_random_instance # TODO Check if current URL is onion instance and use .onion types if so. if data[1]["type"] == "https" # Instances can have statisitics disabled, which is an requirement of version validation. + # as_nil? doesn't exist. Thus we'll have to handle the error rasied if as_nil fails. begin data[1]["stats"].as_nil - statistics_disabled = true next rescue TypeCastError - statistics_disabled = false end # stats endpoint could also lack the software dict. - next if statistics_disabled || data[1]["stats"]["software"]?.nil? + next if data[1]["stats"]["software"]?.nil? # Makes sure the instance isn't too outdated. if remote_version = data[1]["stats"]?.try &.["software"]?.try &.["version"] @@ -451,10 +450,8 @@ def fetch_random_instance next if (remote_commit_date - local_commit_date).abs.days > 30 - # as_nil? doesn't exist. Thus we'll have to handle the error rasied if - # as_nil fails. begin - broken_health_monitoring = data[1]["monitor"].as_nil + data[1]["monitor"].as_nil health = data[1]["monitor"].as_h["dailyRatios"][0].as_h["ratio"] filtered_instance_list << data[0].as_s if health.to_s.to_f > 90 rescue TypeCastError -- cgit v1.2.3 From 7c49a0ba7a92a8c6e527a1fbb75c37980bec0586 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 14 Jun 2021 03:00:07 -0700 Subject: Rephrase auto instance redirect preference --- locales/ar.json | 4 ++-- locales/de.json | 2 +- locales/el.json | 2 +- locales/en-US.json | 2 +- locales/eo.json | 2 +- locales/es.json | 2 +- locales/fa.json | 2 +- locales/fi.json | 2 +- locales/fr.json | 2 +- locales/he.json | 2 +- locales/hr.json | 2 +- locales/id.json | 2 +- locales/is.json | 2 +- locales/it.json | 2 +- locales/ja.json | 2 +- locales/nb-NO.json | 2 +- locales/nl.json | 4 ++-- locales/pl.json | 2 +- locales/pt-BR.json | 2 +- locales/pt-PT.json | 2 +- locales/ro.json | 4 ++-- locales/ru.json | 4 ++-- locales/sv-SE.json | 2 +- locales/tr.json | 2 +- locales/uk.json | 4 ++-- locales/zh-CN.json | 2 +- locales/zh-TW.json | 2 +- src/invidious/views/preferences.ecr | 2 +- 28 files changed, 33 insertions(+), 33 deletions(-) (limited to 'src') diff --git a/locales/ar.json b/locales/ar.json index bc0c6830..119c36e4 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -87,7 +87,7 @@ "light": "فاتح (ابيض)", "Thin mode: ": "الوضع الخفيف: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "تفضيلات الإشتراك", "Show annotations by default for subscribed channels: ": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ", "Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ", @@ -423,4 +423,4 @@ "next_steps_error_message": "", "next_steps_error_message_refresh": "", "next_steps_error_message_go_to_youtube": "" -} \ No newline at end of file +} diff --git a/locales/de.json b/locales/de.json index b602192b..1a8870ec 100644 --- a/locales/de.json +++ b/locales/de.json @@ -87,7 +87,7 @@ "light": "heller Modus", "Thin mode: ": "Schlanker Modus: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Abonnementeinstellungen", "Show annotations by default for subscribed channels: ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ", "Redirect homepage to feed: ": "Startseite zu Feed umleiten: ", diff --git a/locales/el.json b/locales/el.json index f7588c60..e6aec01a 100644 --- a/locales/el.json +++ b/locales/el.json @@ -87,7 +87,7 @@ "light": "φωτεινό", "Thin mode: ": "Ελαφριά λειτουργία: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Προτιμήσεις συνδρομών", "Show annotations by default for subscribed channels: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ", "Redirect homepage to feed: ": "Ανακατεύθυνση αρχικής στη ροή συνδρομών: ", diff --git a/locales/en-US.json b/locales/en-US.json index 106bdcf2..0cddb4c0 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -87,7 +87,7 @@ "light": "light", "Thin mode: ": "Thin mode: ", "Miscellaneous preferences": "Miscellaneous preferences", - "Automatically redirect to another Instance: ": "Automatically redirect to another Instance: ", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automaticatic instance redirection (fallback to redirect.invidious.io): ", "Subscription preferences": "Subscription preferences", "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", "Redirect homepage to feed: ": "Redirect homepage to feed: ", diff --git a/locales/eo.json b/locales/eo.json index 7ac38c35..8be07305 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -87,7 +87,7 @@ "light": "hela", "Thin mode: ": "Maldika reĝimo: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Abonaj agordoj", "Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", "Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ", diff --git a/locales/es.json b/locales/es.json index e160526e..fa377151 100644 --- a/locales/es.json +++ b/locales/es.json @@ -87,7 +87,7 @@ "light": "claro", "Thin mode: ": "Modo compacto: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Preferencias de la suscripción", "Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ", "Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ", diff --git a/locales/fa.json b/locales/fa.json index 611cc57d..145479ad 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -87,7 +87,7 @@ "light": "روشن", "Thin mode: ": "حالت نازک: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "ترجیحات اشتراک", "Show annotations by default for subscribed channels: ": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ", "Redirect homepage to feed: ": "تغییر مسیر صفحه خانه به خوراک: ", diff --git a/locales/fi.json b/locales/fi.json index b446332c..e9cee129 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -87,7 +87,7 @@ "light": "vaalea", "Thin mode: ": "Kapea tila ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Tilausten asetukset", "Show annotations by default for subscribed channels: ": "Näytä oletuksena tilattujen kanavien huomautukset: ", "Redirect homepage to feed: ": "Uudelleenohjaa kotisivu syötteeseen: ", diff --git a/locales/fr.json b/locales/fr.json index 4a685d35..9bb2fe16 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -87,8 +87,8 @@ "light": "clair", "Thin mode: ": "Mode léger : ", "Subscription preferences": "Préférences des abonnements", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", "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 : ", diff --git a/locales/he.json b/locales/he.json index d4c70a36..5345b93d 100644 --- a/locales/he.json +++ b/locales/he.json @@ -87,7 +87,7 @@ "light": "בהיר", "Thin mode: ": "", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "העדפות מינויים", "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", "Redirect homepage to feed: ": "", diff --git a/locales/hr.json b/locales/hr.json index ab9dd54b..2f78469b 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -87,7 +87,7 @@ "light": "svijetlo", "Thin mode: ": "Pojednostavljen prikaz: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Postavke pretplata", "Show annotations by default for subscribed channels: ": "Standardno prikaži napomene za pretplaćene kanale: ", "Redirect homepage to feed: ": "Preusmjeri početnu stranicu na feed: ", diff --git a/locales/id.json b/locales/id.json index 07d252e6..679a3403 100644 --- a/locales/id.json +++ b/locales/id.json @@ -87,7 +87,7 @@ "light": "terang", "Thin mode: ": "Mode tipis: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Preferensi langganan", "Show annotations by default for subscribed channels: ": "Tampilkan anotasi secara default untuk kanal langganan: ", "Redirect homepage to feed: ": "Arahkan kembali laman beranda ke umpan: ", diff --git a/locales/is.json b/locales/is.json index 546d67f8..05893333 100644 --- a/locales/is.json +++ b/locales/is.json @@ -87,7 +87,7 @@ "light": "ljóst", "Thin mode: ": "Þunnt ham: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Áskriftarstillingar", "Show annotations by default for subscribed channels: ": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ", "Redirect homepage to feed: ": "Endurbeina heimasíðu að straumi: ", diff --git a/locales/it.json b/locales/it.json index 1a14f172..ec45d2eb 100644 --- a/locales/it.json +++ b/locales/it.json @@ -87,7 +87,7 @@ "light": "chiaro", "Thin mode: ": "Modalità per connessioni lente: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Preferenze iscrizioni", "Show annotations by default for subscribed channels: ": "Mostrare annotazioni in modo predefinito per i canali sottoscritti: ", "Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ", diff --git a/locales/ja.json b/locales/ja.json index 7a80955d..af5dac7a 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -87,7 +87,7 @@ "light": "ライト", "Thin mode: ": "最小モード: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "登録チャンネル設定", "Show annotations by default for subscribed channels: ": "デフォルトで登録チャンネルのアノテーションを表示しますか? ", "Redirect homepage to feed: ": "ホームからフィードにリダイレクト: ", diff --git a/locales/nb-NO.json b/locales/nb-NO.json index fec2637f..6d66527d 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -87,7 +87,7 @@ "light": "Lys", "Thin mode: ": "Tynt modus: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Abonnementsinnstillinger", "Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ", "Redirect homepage to feed: ": "Videresend hjemmeside til kilde: ", diff --git a/locales/nl.json b/locales/nl.json index 30ddd49f..5f2fa265 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -87,7 +87,7 @@ "light": "licht", "Thin mode: ": "Smalle modus: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Abonnementsinstellingen", "Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ", "Redirect homepage to feed: ": "Startpagina omleiden naar feed: ", @@ -423,4 +423,4 @@ "next_steps_error_message": "", "next_steps_error_message_refresh": "", "next_steps_error_message_go_to_youtube": "" -} \ No newline at end of file +} diff --git a/locales/pl.json b/locales/pl.json index 12177ce6..227a9cbb 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -87,7 +87,7 @@ "light": "jasny", "Thin mode: ": "Tryb minimalny: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Preferencje subskrybcji", "Show annotations by default for subscribed channels: ": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ", "Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ", diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 13f2c65f..7810671e 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -87,7 +87,7 @@ "light": "claro", "Thin mode: ": "Modo compacto: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Preferências de inscrições", "Show annotations by default for subscribed channels: ": "Sempre mostrar anotações dos vídeos de canais inscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para o feed: ", diff --git a/locales/pt-PT.json b/locales/pt-PT.json index acc08c65..0aa19d3f 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -87,7 +87,7 @@ "light": "claro", "Thin mode: ": "Modo compacto: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Preferências de subscrições", "Show annotations by default for subscribed channels: ": "Mostrar sempre anotações aos canais subscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ", diff --git a/locales/ro.json b/locales/ro.json index a1cbb270..71e50ca5 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -87,7 +87,7 @@ "light": "luminos", "Thin mode: ": "Mod lejer: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Preferințele paginii de abonamente", "Show annotations by default for subscribed channels: ": "Afișați adnotările în mod implicit pentru canalele la care v-ați abonat: ", "Redirect homepage to feed: ": "Redirecționați pagina principală la pagina de abonamente: ", @@ -423,4 +423,4 @@ "next_steps_error_message": "", "next_steps_error_message_refresh": "", "next_steps_error_message_go_to_youtube": "" -} \ No newline at end of file +} diff --git a/locales/ru.json b/locales/ru.json index 9381c578..15d97862 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -87,7 +87,7 @@ "light": "светлая", "Thin mode: ": "Облегчённое оформление: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Настройки подписок", "Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ", "Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ", @@ -423,4 +423,4 @@ "next_steps_error_message": "", "next_steps_error_message_refresh": "", "next_steps_error_message_go_to_youtube": "" -} \ No newline at end of file +} diff --git a/locales/sv-SE.json b/locales/sv-SE.json index bc148143..911df096 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -87,7 +87,7 @@ "light": "Ljust", "Thin mode: ": "Lättviktigt läge: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Prenumerationsinställningar", "Show annotations by default for subscribed channels: ": "Visa länkar-i-videor som förval för kanaler som prenumereras på? ", "Redirect homepage to feed: ": "Omdirigera hemsida till flöde: ", diff --git a/locales/tr.json b/locales/tr.json index 6ada31b5..436cb512 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -87,7 +87,7 @@ "light": "aydınlık", "Thin mode: ": "İnce mod: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Abonelik tercihleri", "Show annotations by default for subscribed channels: ": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ", "Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ", diff --git a/locales/uk.json b/locales/uk.json index 6580bb83..91ac8626 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -87,7 +87,7 @@ "light": "Світла", "Thin mode: ": "Полегшене оформлення: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "Налаштування підписок", "Show annotations by default for subscribed channels: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ", "Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ", @@ -423,4 +423,4 @@ "next_steps_error_message": "", "next_steps_error_message_refresh": "", "next_steps_error_message_go_to_youtube": "" -} \ No newline at end of file +} diff --git a/locales/zh-CN.json b/locales/zh-CN.json index fdd87687..e7150627 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -87,7 +87,7 @@ "light": "亮色", "Thin mode: ": "窄页模式: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "订阅设置", "Show annotations by default for subscribed channels: ": "默认情况下显示已订阅频道的注释: ", "Redirect homepage to feed: ": "跳转主页到 feed: ", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 3a060e63..d9a486ef 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -87,7 +87,7 @@ "light": "淺色", "Thin mode: ": "精簡模式: ", "Miscellaneous preferences": "", - "Automatically redirect to another Instance: ": "", + "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "", "Subscription preferences": "訂閱偏好設定", "Show annotations by default for subscribed channels: ": "預設為已訂閱的頻道顯示註釋: ", "Redirect homepage to feed: ": "重新導向首頁至 feed: ", diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index bc09ec79..c5b64ad6 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -179,7 +179,7 @@ <%= translate(locale, "Miscellaneous preferences") %>
- + checked<% end %>>
-- cgit v1.2.3 From 09f7e38eed63f37e0a2a07c4acd48a5339589b7f Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Mon, 14 Jun 2021 04:59:36 -0700 Subject: Disable automatic instance redirection by default --- src/invidious/helpers/helpers.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index b020d4fe..0c70cb02 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -44,7 +44,7 @@ struct ConfigPreferences property quality_dash : String = "auto" property default_home : String? = "Popular" property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] - property automatic_instance_redirect : Bool = true + property automatic_instance_redirect : Bool = false property related_videos : Bool = true property sort : String = "published" property speed : Float32 = 1.0_f32 -- cgit v1.2.3 From cb525af0a28aa8e8d3394eb3074eee39ae0b594a Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 16 Jun 2021 01:32:33 -0700 Subject: Connect to api.invidious.io with https --- src/invidious/helpers/utils.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index c53d5ee0..6ee07d7a 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -412,7 +412,7 @@ end def fetch_random_instance begin - instance_api_client = HTTP::Client.new("api.invidious.io") + instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io")) # Timeouts instance_api_client.connect_timeout = 10.seconds -- cgit v1.2.3 From 90c907710cdd4919a539a5a428316fd7efb715ea Mon Sep 17 00:00:00 2001 From: Mateusz Makowski Date: Sun, 23 Feb 2020 13:23:25 +0100 Subject: Display username in header --- locales/ar.json | 2 ++ locales/de.json | 2 ++ locales/el.json | 2 ++ locales/en-US.json | 2 ++ locales/eo.json | 2 ++ locales/es.json | 2 ++ locales/fa.json | 2 ++ locales/fi.json | 2 ++ locales/fr.json | 2 ++ locales/he.json | 2 ++ locales/hr.json | 2 ++ locales/id.json | 2 ++ locales/is.json | 2 ++ locales/it.json | 2 ++ locales/ja.json | 2 ++ locales/nb-NO.json | 2 ++ locales/nl.json | 2 ++ locales/pl.json | 2 ++ locales/pt-BR.json | 2 ++ locales/pt-PT.json | 2 ++ locales/ro.json | 2 ++ locales/ru.json | 2 ++ locales/sv-SE.json | 2 ++ locales/tr.json | 2 ++ locales/uk.json | 2 ++ locales/zh-CN.json | 2 ++ locales/zh-TW.json | 2 ++ src/invidious/helpers/helpers.cr | 1 + src/invidious/routes/preferences.cr | 5 +++++ src/invidious/users.cr | 1 + src/invidious/views/preferences.ecr | 6 ++++++ src/invidious/views/template.ecr | 5 +++++ 32 files changed, 72 insertions(+) (limited to 'src') diff --git a/locales/ar.json b/locales/ar.json index 119c36e4..65d1587e 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -117,6 +117,8 @@ "Administrator preferences": "إعدادات المدير", "Default homepage: ": "الصفحة الرئيسية الافتراضية ", "Feed menu: ": "قائمة التدفقات: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "تفعيل 'الأفضل' ؟ ", "CAPTCHA enabled: ": "تفعيل الكابتشا: ", "Login enabled: ": "تفعيل الولوج: ", diff --git a/locales/de.json b/locales/de.json index 1a8870ec..a799c68d 100644 --- a/locales/de.json +++ b/locales/de.json @@ -117,6 +117,8 @@ "Administrator preferences": "Administrator-Einstellungen", "Default homepage: ": "Standard-Startseite: ", "Feed menu: ": "Feed-Menü: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Top aktiviert? ", "CAPTCHA enabled: ": "CAPTCHA aktiviert? ", "Login enabled: ": "Anmeldung aktiviert: ", diff --git a/locales/el.json b/locales/el.json index e6aec01a..489dafe6 100644 --- a/locales/el.json +++ b/locales/el.json @@ -117,6 +117,8 @@ "Administrator preferences": "Προτιμήσεις διαχειριστή", "Default homepage: ": "Προεπιλεγμένη αρχική: ", "Feed menu: ": "Μενού ροής συνδρομών: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Ενεργοποίηση κορυφαίων; ", "CAPTCHA enabled: ": "Ενεργοποίηση CAPTCHA; ", "Login enabled: ": "Ενεργοποίηση σύνδεσης; ", diff --git a/locales/en-US.json b/locales/en-US.json index 0cddb4c0..cf1b9141 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -117,6 +117,8 @@ "Administrator preferences": "Administrator preferences", "Default homepage: ": "Default homepage: ", "Feed menu: ": "Feed menu: ", + "Show nickname on top: ": "Show nickname on top: ", + "Welcome, `x`": "Welcome, `x`", "Top enabled: ": "Top enabled: ", "CAPTCHA enabled: ": "CAPTCHA enabled: ", "Login enabled: ": "Login enabled: ", diff --git a/locales/eo.json b/locales/eo.json index 8be07305..e76fae71 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -117,6 +117,8 @@ "Administrator preferences": "Agordoj de administranto", "Default homepage: ": "Defaŭlta hejmpaĝo: ", "Feed menu: ": "Flua menuo: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Ĉu pli bonaj ŝaltitaj? ", "CAPTCHA enabled: ": "Ĉu CAPTCHA ŝaltita? ", "Login enabled: ": "Ĉu ensaluto aktivita? ", diff --git a/locales/es.json b/locales/es.json index fa377151..7507779e 100644 --- a/locales/es.json +++ b/locales/es.json @@ -117,6 +117,8 @@ "Administrator preferences": "Preferencias de administrador", "Default homepage: ": "Página de inicio por defecto: ", "Feed menu: ": "Menú de fuentes: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "¿Habilitar los destacados? ", "CAPTCHA enabled: ": "¿Habilitar los CAPTCHA? ", "Login enabled: ": "¿Habilitar el inicio de sesión? ", diff --git a/locales/fa.json b/locales/fa.json index 145479ad..bd7691cb 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -117,6 +117,8 @@ "Administrator preferences": "ترجیحات مدیریت", "Default homepage: ": "صفحه خانه پیشفرض ", "Feed menu: ": "منو خوراک: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "بالا فعال شده: ", "CAPTCHA enabled: ": "CAPTCHA فعال شده: ", "Login enabled: ": "ورود فعال شده: ", diff --git a/locales/fi.json b/locales/fi.json index e9cee129..eb4337da 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -117,6 +117,8 @@ "Administrator preferences": "Järjestelmänvalvojan asetukset", "Default homepage: ": "Oletuskotisivu: ", "Feed menu: ": "Syötevalikko: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Yläosa käytössä: ", "CAPTCHA enabled: ": "CAPTCHA käytössä: ", "Login enabled: ": "Kirjautuminen käytössä: ", diff --git a/locales/fr.json b/locales/fr.json index 9bb2fe16..2f8a0039 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -117,6 +117,8 @@ "Administrator preferences": "Préferences d'Administration", "Default homepage: ": "Page d'accueil par défaut : ", "Feed menu: ": "Préferences des abonnements : ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Top activé : ", "CAPTCHA enabled: ": "CAPTCHA activé : ", "Login enabled: ": "Autoriser l'ouverture de sessions utilisateur : ", diff --git a/locales/he.json b/locales/he.json index 5345b93d..177e07a8 100644 --- a/locales/he.json +++ b/locales/he.json @@ -117,6 +117,8 @@ "Administrator preferences": "הגדרות ניהול מערכת", "Default homepage: ": "Default homepage: ", "Feed menu: ": "תפריט ההזנה: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "", "CAPTCHA enabled: ": "", "Login enabled: ": "", diff --git a/locales/hr.json b/locales/hr.json index 2f78469b..6d16812f 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -117,6 +117,8 @@ "Administrator preferences": "Postavke administratora", "Default homepage: ": "Standardna početna stranica: ", "Feed menu: ": "Izbornik za feedove: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Najbolji aktivirani: ", "CAPTCHA enabled: ": "Aktivirani CAPTCHA: ", "Login enabled: ": "Prijava aktivirana: ", diff --git a/locales/id.json b/locales/id.json index 679a3403..970c19f1 100644 --- a/locales/id.json +++ b/locales/id.json @@ -117,6 +117,8 @@ "Administrator preferences": "Preferensi administrator", "Default homepage: ": "Laman beranda default: ", "Feed menu: ": "Menu umpan: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Teratas diaktifkan: ", "CAPTCHA enabled: ": "CAPTCHA diaktifkan: ", "Login enabled: ": "Masuk diaktifkan: ", diff --git a/locales/is.json b/locales/is.json index 05893333..2fbbfb3b 100644 --- a/locales/is.json +++ b/locales/is.json @@ -117,6 +117,8 @@ "Administrator preferences": "Kjörstillingar stjórnanda", "Default homepage: ": "Sjálfgefin heimasíða: ", "Feed menu: ": "Straum valmynd: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Toppur virkur? ", "CAPTCHA enabled: ": "CAPTCHA virk? ", "Login enabled: ": "Innskráning virk? ", diff --git a/locales/it.json b/locales/it.json index ec45d2eb..d2e7c0ff 100644 --- a/locales/it.json +++ b/locales/it.json @@ -117,6 +117,8 @@ "Administrator preferences": "Preferenze amministratore", "Default homepage: ": "Pagina principale predefinita: ", "Feed menu: ": "Menu iscrizioni: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Top abilitato: ", "CAPTCHA enabled: ": "CAPTCHA attivati: ", "Login enabled: ": "Accesso attivato: ", diff --git a/locales/ja.json b/locales/ja.json index af5dac7a..63bff9da 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -117,6 +117,8 @@ "Administrator preferences": "管理者設定", "Default homepage: ": "デフォルトのホーム: ", "Feed menu: ": "フィードメニュー: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "トップページを有効化: ", "CAPTCHA enabled: ": "CAPTCHA を有効化: ", "Login enabled: ": "ログインを有効化: ", diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 6d66527d..0ec779da 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -117,6 +117,8 @@ "Administrator preferences": "Administratorinnstillinger", "Default homepage: ": "Forvalgt hjemmeside: ", "Feed menu: ": "Kilde-meny: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Topp påskrudd? ", "CAPTCHA enabled: ": "CAPTCHA påskrudd? ", "Login enabled: ": "Innlogging påskrudd? ", diff --git a/locales/nl.json b/locales/nl.json index 5f2fa265..d896f03c 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -117,6 +117,8 @@ "Administrator preferences": "Beheerdersinstellingen", "Default homepage: ": "Standaard startpagina: ", "Feed menu: ": "Feedmenu: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Bovenkant inschakelen? ", "CAPTCHA enabled: ": "CAPTCHA gebruiken? ", "Login enabled: ": "Inloggen toestaan? ", diff --git a/locales/pl.json b/locales/pl.json index 227a9cbb..83711e2e 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -117,6 +117,8 @@ "Administrator preferences": "Preferencje administratora", "Default homepage: ": "Domyślna strona główna: ", "Feed menu: ": "Menu aktualności: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "\"Top\" aktywne: ", "CAPTCHA enabled: ": "CAPTCHA aktywna? ", "Login enabled: ": "Logowanie włączone? ", diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 7810671e..940e1d55 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -117,6 +117,8 @@ "Administrator preferences": "Preferências de administrador", "Default homepage: ": "Página de início padrão: ", "Feed menu: ": "Menu do feed: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Habilitar destaques: ", "CAPTCHA enabled: ": "Habilitar CAPTCHA: ", "Login enabled: ": "Habilitar login: ", diff --git a/locales/pt-PT.json b/locales/pt-PT.json index 0aa19d3f..a90e72fb 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -117,6 +117,8 @@ "Administrator preferences": "Preferências de administrador", "Default homepage: ": "Página inicial predefinida: ", "Feed menu: ": "Menu de subscrições: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Top ativado: ", "CAPTCHA enabled: ": "CAPTCHA ativado: ", "Login enabled: ": "Iniciar sessão ativado: ", diff --git a/locales/ro.json b/locales/ro.json index 71e50ca5..e93b686e 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -117,6 +117,8 @@ "Administrator preferences": "Preferințele Administratorului", "Default homepage: ": "Pagina principală implicită: ", "Feed menu: ": "Preferințe legate de pagina de abonamente: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Top activat: ", "CAPTCHA enabled: ": "CAPTCHA activat : ", "Login enabled: ": "Autentificare activată : ", diff --git a/locales/ru.json b/locales/ru.json index 15d97862..67d924bd 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -117,6 +117,8 @@ "Administrator preferences": "Администраторские настройки", "Default homepage: ": "Главная страница по умолчанию: ", "Feed menu: ": "Меню ленты видео: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Включить топ видео? ", "CAPTCHA enabled: ": "Включить капчу? ", "Login enabled: ": "Включить авторизацию? ", diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 911df096..25ac23e5 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -117,6 +117,8 @@ "Administrator preferences": "Administratörsinställningar", "Default homepage: ": "Förvald hemsida: ", "Feed menu: ": "Flödesmeny: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Topp påslaget? ", "CAPTCHA enabled: ": "CAPTCHA påslaget? ", "Login enabled: ": "Inloggning påslaget? ", diff --git a/locales/tr.json b/locales/tr.json index 436cb512..021e9fd6 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -117,6 +117,8 @@ "Administrator preferences": "Yönetici tercihleri", "Default homepage: ": "Öntanımlı ana sayfa: ", "Feed menu: ": "Akış menüsü: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Top etkin: ", "CAPTCHA enabled: ": "CAPTCHA etkin: ", "Login enabled: ": "Oturum açma etkin: ", diff --git a/locales/uk.json b/locales/uk.json index 91ac8626..da1e80eb 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -117,6 +117,8 @@ "Administrator preferences": "Адміністраторські налаштування", "Default homepage: ": "Усталена домашня сторінка: ", "Feed menu: ": "Меню потоку з відео: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "Увімкнути топ відео? ", "CAPTCHA enabled: ": "Увімкнути капчу? ", "Login enabled: ": "Увімкнути авторизацію? ", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index e7150627..5952b4e0 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -117,6 +117,8 @@ "Administrator preferences": "管理员选项", "Default homepage: ": "默认主页: ", "Feed menu: ": "Feed 菜单: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "是否启用“热门视频”页: ", "CAPTCHA enabled: ": "是否启用验证码: ", "Login enabled: ": "是否启用登录: ", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index d9a486ef..c8644de3 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -117,6 +117,8 @@ "Administrator preferences": "管理員偏好設定", "Default homepage: ": "預設首頁: ", "Feed menu: ": "Feed 選單: ", + "Show nickname on top: ": "", + "Welcome, `x`": "", "Top enabled: ": "頂部啟用: ", "CAPTCHA enabled: ": "CAPTCHA 啟用: ", "Login enabled: ": "啟用登入: ", diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 0c70cb02..7353f2d9 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -54,6 +54,7 @@ struct ConfigPreferences property extend_desc : Bool = false property volume : Int32 = 100 property vr_mode : Bool = true + property show_nick : Bool = true def to_tuple {% begin %} diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index d6002ffd..21d79218 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -68,6 +68,10 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute vr_mode ||= "off" vr_mode = vr_mode == "on" + show_nick = env.params.body["show_nick"]?.try &.as(String) + show_nick ||= "off" + show_nick = show_nick == "on" + comments = [] of String 2.times do |i| comments << (env.params.body["comments[#{i}]"]?.try &.as(String) || CONFIG.default_user_preferences.comments[i]) @@ -155,6 +159,7 @@ class Invidious::Routes::PreferencesRoute < Invidious::Routes::BaseRoute volume: volume, extend_desc: extend_desc, vr_mode: vr_mode, + show_nick: show_nick, }.to_json).to_json if user = env.get? "user" diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 98ef8792..aff76b53 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -55,6 +55,7 @@ struct Preferences property listen : Bool = CONFIG.default_user_preferences.listen property local : Bool = CONFIG.default_user_preferences.local property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode + property show_nick : Bool = CONFIG.default_user_preferences.show_nick @[JSON::Field(converter: Preferences::ProcessString)] property locale : String = CONFIG.default_user_preferences.locale diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index c5b64ad6..d98c3bb5 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -175,6 +175,12 @@ <% end %>
+ <% if env.get? "user" %> +
+ + checked<% end %>> +
+ <% end %> <%= translate(locale, "Miscellaneous preferences") %> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index a13d3928..070b3087 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -67,6 +67,11 @@
+ <% if env.get("preferences").as(Preferences).show_nick %> +
+ <%= translate(locale, "Welcome, `x`", env.get("user").as(User).email) %> +
+ <% end %>
" method="post"> "> -- cgit v1.2.3 From 349f073b8e8eec7562dae39a0ff33c4984c08cc6 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 19 Jun 2021 06:01:32 -0700 Subject: Remove unnecessary "Welcome, " on username display --- locales/ar.json | 1 - locales/de.json | 1 - locales/el.json | 1 - locales/en-US.json | 1 - locales/eo.json | 1 - locales/es.json | 1 - locales/fa.json | 1 - locales/fi.json | 1 - locales/fr.json | 1 - locales/he.json | 1 - locales/hr.json | 1 - locales/id.json | 1 - locales/is.json | 1 - locales/it.json | 1 - locales/ja.json | 1 - locales/nb-NO.json | 1 - locales/nl.json | 1 - locales/pl.json | 1 - locales/pt-BR.json | 1 - locales/pt-PT.json | 1 - locales/ro.json | 1 - locales/ru.json | 1 - locales/sv-SE.json | 1 - locales/tr.json | 1 - locales/uk.json | 1 - locales/zh-CN.json | 1 - locales/zh-TW.json | 1 - src/invidious/views/template.ecr | 2 +- 28 files changed, 1 insertion(+), 28 deletions(-) (limited to 'src') diff --git a/locales/ar.json b/locales/ar.json index 65d1587e..f4fda666 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -118,7 +118,6 @@ "Default homepage: ": "الصفحة الرئيسية الافتراضية ", "Feed menu: ": "قائمة التدفقات: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "تفعيل 'الأفضل' ؟ ", "CAPTCHA enabled: ": "تفعيل الكابتشا: ", "Login enabled: ": "تفعيل الولوج: ", diff --git a/locales/de.json b/locales/de.json index a799c68d..9406f2eb 100644 --- a/locales/de.json +++ b/locales/de.json @@ -118,7 +118,6 @@ "Default homepage: ": "Standard-Startseite: ", "Feed menu: ": "Feed-Menü: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Top aktiviert? ", "CAPTCHA enabled: ": "CAPTCHA aktiviert? ", "Login enabled: ": "Anmeldung aktiviert: ", diff --git a/locales/el.json b/locales/el.json index 489dafe6..830fb0fe 100644 --- a/locales/el.json +++ b/locales/el.json @@ -118,7 +118,6 @@ "Default homepage: ": "Προεπιλεγμένη αρχική: ", "Feed menu: ": "Μενού ροής συνδρομών: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Ενεργοποίηση κορυφαίων; ", "CAPTCHA enabled: ": "Ενεργοποίηση CAPTCHA; ", "Login enabled: ": "Ενεργοποίηση σύνδεσης; ", diff --git a/locales/en-US.json b/locales/en-US.json index cf1b9141..88daa4eb 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -118,7 +118,6 @@ "Default homepage: ": "Default homepage: ", "Feed menu: ": "Feed menu: ", "Show nickname on top: ": "Show nickname on top: ", - "Welcome, `x`": "Welcome, `x`", "Top enabled: ": "Top enabled: ", "CAPTCHA enabled: ": "CAPTCHA enabled: ", "Login enabled: ": "Login enabled: ", diff --git a/locales/eo.json b/locales/eo.json index e76fae71..f78c27cf 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -118,7 +118,6 @@ "Default homepage: ": "Defaŭlta hejmpaĝo: ", "Feed menu: ": "Flua menuo: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Ĉu pli bonaj ŝaltitaj? ", "CAPTCHA enabled: ": "Ĉu CAPTCHA ŝaltita? ", "Login enabled: ": "Ĉu ensaluto aktivita? ", diff --git a/locales/es.json b/locales/es.json index 7507779e..894e3b0d 100644 --- a/locales/es.json +++ b/locales/es.json @@ -118,7 +118,6 @@ "Default homepage: ": "Página de inicio por defecto: ", "Feed menu: ": "Menú de fuentes: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "¿Habilitar los destacados? ", "CAPTCHA enabled: ": "¿Habilitar los CAPTCHA? ", "Login enabled: ": "¿Habilitar el inicio de sesión? ", diff --git a/locales/fa.json b/locales/fa.json index bd7691cb..d449948a 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -118,7 +118,6 @@ "Default homepage: ": "صفحه خانه پیشفرض ", "Feed menu: ": "منو خوراک: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "بالا فعال شده: ", "CAPTCHA enabled: ": "CAPTCHA فعال شده: ", "Login enabled: ": "ورود فعال شده: ", diff --git a/locales/fi.json b/locales/fi.json index eb4337da..60c2aed6 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -118,7 +118,6 @@ "Default homepage: ": "Oletuskotisivu: ", "Feed menu: ": "Syötevalikko: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Yläosa käytössä: ", "CAPTCHA enabled: ": "CAPTCHA käytössä: ", "Login enabled: ": "Kirjautuminen käytössä: ", diff --git a/locales/fr.json b/locales/fr.json index 2f8a0039..b3931b36 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -118,7 +118,6 @@ "Default homepage: ": "Page d'accueil par défaut : ", "Feed menu: ": "Préferences des abonnements : ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Top activé : ", "CAPTCHA enabled: ": "CAPTCHA activé : ", "Login enabled: ": "Autoriser l'ouverture de sessions utilisateur : ", diff --git a/locales/he.json b/locales/he.json index 177e07a8..cb3f94e5 100644 --- a/locales/he.json +++ b/locales/he.json @@ -118,7 +118,6 @@ "Default homepage: ": "Default homepage: ", "Feed menu: ": "תפריט ההזנה: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "", "CAPTCHA enabled: ": "", "Login enabled: ": "", diff --git a/locales/hr.json b/locales/hr.json index 6d16812f..d278a6ef 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -118,7 +118,6 @@ "Default homepage: ": "Standardna početna stranica: ", "Feed menu: ": "Izbornik za feedove: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Najbolji aktivirani: ", "CAPTCHA enabled: ": "Aktivirani CAPTCHA: ", "Login enabled: ": "Prijava aktivirana: ", diff --git a/locales/id.json b/locales/id.json index 970c19f1..896cf0d5 100644 --- a/locales/id.json +++ b/locales/id.json @@ -118,7 +118,6 @@ "Default homepage: ": "Laman beranda default: ", "Feed menu: ": "Menu umpan: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Teratas diaktifkan: ", "CAPTCHA enabled: ": "CAPTCHA diaktifkan: ", "Login enabled: ": "Masuk diaktifkan: ", diff --git a/locales/is.json b/locales/is.json index 2fbbfb3b..73665c49 100644 --- a/locales/is.json +++ b/locales/is.json @@ -118,7 +118,6 @@ "Default homepage: ": "Sjálfgefin heimasíða: ", "Feed menu: ": "Straum valmynd: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Toppur virkur? ", "CAPTCHA enabled: ": "CAPTCHA virk? ", "Login enabled: ": "Innskráning virk? ", diff --git a/locales/it.json b/locales/it.json index d2e7c0ff..676bd650 100644 --- a/locales/it.json +++ b/locales/it.json @@ -118,7 +118,6 @@ "Default homepage: ": "Pagina principale predefinita: ", "Feed menu: ": "Menu iscrizioni: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Top abilitato: ", "CAPTCHA enabled: ": "CAPTCHA attivati: ", "Login enabled: ": "Accesso attivato: ", diff --git a/locales/ja.json b/locales/ja.json index 63bff9da..4f3f155a 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -118,7 +118,6 @@ "Default homepage: ": "デフォルトのホーム: ", "Feed menu: ": "フィードメニュー: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "トップページを有効化: ", "CAPTCHA enabled: ": "CAPTCHA を有効化: ", "Login enabled: ": "ログインを有効化: ", diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 0ec779da..2eb30083 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -118,7 +118,6 @@ "Default homepage: ": "Forvalgt hjemmeside: ", "Feed menu: ": "Kilde-meny: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Topp påskrudd? ", "CAPTCHA enabled: ": "CAPTCHA påskrudd? ", "Login enabled: ": "Innlogging påskrudd? ", diff --git a/locales/nl.json b/locales/nl.json index d896f03c..9597e1bd 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -118,7 +118,6 @@ "Default homepage: ": "Standaard startpagina: ", "Feed menu: ": "Feedmenu: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Bovenkant inschakelen? ", "CAPTCHA enabled: ": "CAPTCHA gebruiken? ", "Login enabled: ": "Inloggen toestaan? ", diff --git a/locales/pl.json b/locales/pl.json index 83711e2e..10cb0cc2 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -118,7 +118,6 @@ "Default homepage: ": "Domyślna strona główna: ", "Feed menu: ": "Menu aktualności: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "\"Top\" aktywne: ", "CAPTCHA enabled: ": "CAPTCHA aktywna? ", "Login enabled: ": "Logowanie włączone? ", diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 940e1d55..5af56ddf 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -118,7 +118,6 @@ "Default homepage: ": "Página de início padrão: ", "Feed menu: ": "Menu do feed: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Habilitar destaques: ", "CAPTCHA enabled: ": "Habilitar CAPTCHA: ", "Login enabled: ": "Habilitar login: ", diff --git a/locales/pt-PT.json b/locales/pt-PT.json index a90e72fb..1384648c 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -118,7 +118,6 @@ "Default homepage: ": "Página inicial predefinida: ", "Feed menu: ": "Menu de subscrições: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Top ativado: ", "CAPTCHA enabled: ": "CAPTCHA ativado: ", "Login enabled: ": "Iniciar sessão ativado: ", diff --git a/locales/ro.json b/locales/ro.json index e93b686e..ce961c39 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -118,7 +118,6 @@ "Default homepage: ": "Pagina principală implicită: ", "Feed menu: ": "Preferințe legate de pagina de abonamente: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Top activat: ", "CAPTCHA enabled: ": "CAPTCHA activat : ", "Login enabled: ": "Autentificare activată : ", diff --git a/locales/ru.json b/locales/ru.json index 67d924bd..bd2bf360 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -118,7 +118,6 @@ "Default homepage: ": "Главная страница по умолчанию: ", "Feed menu: ": "Меню ленты видео: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Включить топ видео? ", "CAPTCHA enabled: ": "Включить капчу? ", "Login enabled: ": "Включить авторизацию? ", diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 25ac23e5..7aaaab7b 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -118,7 +118,6 @@ "Default homepage: ": "Förvald hemsida: ", "Feed menu: ": "Flödesmeny: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Topp påslaget? ", "CAPTCHA enabled: ": "CAPTCHA påslaget? ", "Login enabled: ": "Inloggning påslaget? ", diff --git a/locales/tr.json b/locales/tr.json index 021e9fd6..1b3b55d7 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -118,7 +118,6 @@ "Default homepage: ": "Öntanımlı ana sayfa: ", "Feed menu: ": "Akış menüsü: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Top etkin: ", "CAPTCHA enabled: ": "CAPTCHA etkin: ", "Login enabled: ": "Oturum açma etkin: ", diff --git a/locales/uk.json b/locales/uk.json index da1e80eb..e51aa5ba 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -118,7 +118,6 @@ "Default homepage: ": "Усталена домашня сторінка: ", "Feed menu: ": "Меню потоку з відео: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "Увімкнути топ відео? ", "CAPTCHA enabled: ": "Увімкнути капчу? ", "Login enabled: ": "Увімкнути авторизацію? ", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 5952b4e0..3ec7980a 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -118,7 +118,6 @@ "Default homepage: ": "默认主页: ", "Feed menu: ": "Feed 菜单: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "是否启用“热门视频”页: ", "CAPTCHA enabled: ": "是否启用验证码: ", "Login enabled: ": "是否启用登录: ", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index c8644de3..c651bcc0 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -118,7 +118,6 @@ "Default homepage: ": "預設首頁: ", "Feed menu: ": "Feed 選單: ", "Show nickname on top: ": "", - "Welcome, `x`": "", "Top enabled: ": "頂部啟用: ", "CAPTCHA enabled: ": "CAPTCHA 啟用: ", "Login enabled: ": "啟用登入: ", diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 070b3087..d0bdd742 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -69,7 +69,7 @@
<% if env.get("preferences").as(Preferences).show_nick %>
- <%= translate(locale, "Welcome, `x`", env.get("user").as(User).email) %> + <%= env.get("user").as(User).email %>
<% end %>
-- cgit v1.2.3 From 4803285e50b3799986b5b88b3953d0e147278626 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Sat, 19 Jun 2021 17:38:49 +0000 Subject: update video URL for recaptcha detection --- src/invidious/jobs/bypass_captcha_job.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr index e68b81e6..87cf7688 100644 --- a/src/invidious/jobs/bypass_captcha_job.cr +++ b/src/invidious/jobs/bypass_captcha_job.cr @@ -2,7 +2,7 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob def begin loop do begin - {"/watch?v=jNQXAC9IVRw&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: "UC4QobU6STFB0P71PMvOGN5A")}.each do |path| + {"/watch?v=zj82_v2R6ts&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: "UCK87Lox575O_HCHBWaBSyGA")}.each do |path| response = YT_POOL.client &.get(path) if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") html = XML.parse_html(response.body) -- cgit v1.2.3 From 5a8825d01682def020acfd2baf95a44b94790f6f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 20 Jun 2021 18:43:00 +0200 Subject: Fix quoting of 'none' in CSP header The keyword 'none' must be surrounded by single quotes. Regression introduced by #2168. --- src/invidious.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index b1ee1525..f7c8980a 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -187,7 +187,7 @@ before_all do |env| if env.request.resource.starts_with?("/embed") frame_ancestors = "'self' http: https:" else - frame_ancestors = "none" + frame_ancestors = "'none'" end # TODO: Remove style-src's 'unsafe-inline', requires to remove all -- cgit v1.2.3 From 50267a6dd60d8bb95c477e33e4824f8b19bc6424 Mon Sep 17 00:00:00 2001 From: bopol Date: Thu, 24 Jun 2021 00:08:40 +0200 Subject: Use youtubei API for trending --- src/invidious/helpers/youtube_api.cr | 10 ++++++---- src/invidious/trending.cr | 30 +++++++++--------------------- 2 files changed, 15 insertions(+), 25 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr index e27d4088..734fddcd 100644 --- a/src/invidious/helpers/youtube_api.cr +++ b/src/invidious/helpers/youtube_api.cr @@ -25,12 +25,14 @@ end #################################################################### # request_youtube_api_browse(continuation) -# request_youtube_api_browse(browse_id, params) +# request_youtube_api_browse(browse_id, params, region) # # Requests the youtubei/v1/browse endpoint with the required headers -# and POST data in order to get a JSON reply in english US that can +# and POST data in order to get a JSON reply in english that can # be easily parsed. # +# The region can be provided, default is US. +# # The requested data can either be: # # - A continuation token (ctoken). Depending on this token's @@ -49,11 +51,11 @@ def request_youtube_api_browse(continuation : String) return _youtube_api_post_json("/youtubei/v1/browse", data) end -def request_youtube_api_browse(browse_id : String, params : String) +def request_youtube_api_browse(browse_id : String, params : String, region : String = "US") # JSON Request data, required by the API data = { "browseId" => browse_id, - "context" => make_youtube_api_context("US"), + "context" => make_youtube_api_context(region), } # Append the additionnal parameters if those were provided diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 910a99d8..2ab1e7ba 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -2,31 +2,19 @@ def fetch_trending(trending_type, region, locale) region ||= "US" region = region.upcase - trending = "" plid = nil - if trending_type && trending_type != "Default" - if trending_type == "Music" - trending_type = 1 - elsif trending_type == "Gaming" - trending_type = 2 - elsif trending_type == "Movies" - trending_type = 3 - end - - response = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body - - initial_data = extract_initial_data(response) - url = initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"][trending_type]["tabRenderer"]["endpoint"]["commandMetadata"]["webCommandMetadata"]["url"] - url = "#{url}&gl=#{region}&hl=en" - - trending = YT_POOL.client &.get(url).body - plid = extract_plid(url) - else - trending = YT_POOL.client &.get("/feed/trending?gl=#{region}&hl=en").body + if trending_type == "Music" + params = "4gINGgt5dG1hX2NoYXJ0cw%3D%3D" + elsif trending_type == "Gaming" + params = "4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D" + elsif trending_type == "Movies" + params = "4gIKGgh0cmFpbGVycw%3D%3D" + else # Default + params = "" end - initial_data = extract_initial_data(trending) + initial_data = request_youtube_api_browse("FEtrending", params: params, region: region) trending = extract_videos(initial_data) return {trending, plid} -- cgit v1.2.3 From 7ec93825b6e92667b345463d8d42e66764a83a57 Mon Sep 17 00:00:00 2001 From: Cadence Ember Date: Thu, 24 Jun 2021 21:05:06 +1200 Subject: Change description-box from flex to block I also make minor changes to the surroundings so that the same layout and functionality as before is preserved. --- assets/css/default.css | 7 +------ src/invidious/views/watch.ecr | 10 +++++----- 2 files changed, 6 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index de295501..808df295 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -492,11 +492,6 @@ hr { } /* Description Expansion Styling*/ -#description-box { - display: flex; - flex-direction: column; -} - #descexpansionbutton { display: none } @@ -511,7 +506,7 @@ hr { height: 100%; } -#descexpansionbutton + label { +#descexpansionbutton ~ label { order: 1; margin-top: 20px; } diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 91e03725..8ea83384 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -30,11 +30,11 @@ we're going to need to do it here in order to allow for translations. --> @@ -249,12 +249,12 @@ we're going to need to do it here in order to allow for translations. <%= video.description_html %> <% else %> -
<%= video.description_html %>
+ <% end %>
-- cgit v1.2.3 From d16a748f37c6ca034480693ddb7433f351241cf6 Mon Sep 17 00:00:00 2001 From: Mostafa Ahangarha Date: Thu, 24 Jun 2021 17:08:40 +0430 Subject: set alignment for feed link --- src/invidious/views/channel.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 21038394..2f734b2c 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -21,7 +21,7 @@
-

+

-- cgit v1.2.3 From 9e4fd193c6f8a8a00243d04abba0ea4393ad10b7 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 24 Jun 2021 15:00:55 +0200 Subject: Limit descriptions width to ease mixed LTR/RTL text reading This will prevent, on large pages, the LTR and RTL text to be far away, on each side of the page. This could happen on channel and playlists descriptions, when the page is displayed on a large screen. --- assets/css/default.css | 4 ++++ src/invidious/views/channel.ecr | 4 +++- src/invidious/views/community.ecr | 4 +++- src/invidious/views/playlist.ecr | 4 +++- src/invidious/views/playlists.ecr | 4 +++- src/invidious/views/watch.ecr | 4 +++- 6 files changed, 19 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 808df295..1df63412 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -523,3 +523,7 @@ p, unicode-bidi: plaintext; text-align: start; } + +#descriptionWrapper { + max-width: 600px; +} diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 2f734b2c..dd2807de 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -28,7 +28,9 @@
-

<%= channel.description_html %>

+
+

<%= channel.description_html %>

+
diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index b0092e5f..bc6fb631 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -27,7 +27,9 @@
-

<%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %>

+
+

<%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %>

+
diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index a19dd182..377da20f 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -64,7 +64,9 @@
-

<%= playlist.description_html %>

+
+

<%= playlist.description_html %>

+
<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 975ccd6c..52b79a40 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -27,7 +27,9 @@
-

<%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content if !channel.description_html.empty? %>

+
+

<%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content if !channel.description_html.empty? %>

+
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 8ea83384..7ee3fb1a 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -246,7 +246,9 @@ we're going to need to do it here in order to allow for translations.
<% if video.description.size < 200 || params.extend_desc %> - <%= video.description_html %> +
+ <%= video.description_html %> +
<% else %>
-- cgit v1.2.3 From 1b1932f7873fa360b1e66071aa8c79c8702a8a69 Mon Sep 17 00:00:00 2001 From: Mostafa Ahangarha Date: Thu, 24 Jun 2021 17:40:02 +0430 Subject: fix feed alignment on community view --- src/invidious/views/community.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index bc6fb631..96976271 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -20,7 +20,7 @@
-

+

-- cgit v1.2.3 From 9cef7945c04620eb0b18a200aa0db803dba99c5a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 25 Jun 2021 02:03:05 +0200 Subject: Fix RTL text in video titles on Firefox The behavior was as follow: on Right-To-Left text (e.g Arabic) that is wrapped (because it's too long to fit on one line), the second row and following rows may or may not be right aligned (as RTL text should be). Opening the devtools fixes that alignement, as consistently as closing the devtool breaks it. This problem seems to arrive only in the following configurations (link nested in a paragraph, both of which may or may not have the dir= attribute): * `

RTL_TEXT

` * `

RTL_TEXT

` * `

RTL_TEXT

` with the following CSS: ``` p { unicode-bidi: plaintext; text-align: start; } ``` Changing the HTML to the following configuration (a paragraph with the dir= attribute, nested in a link) seems to fix it: `

RTL_TEXT

` --- src/invidious/views/components/item.ecr | 49 +++++++++++++++------------------ 1 file changed, 22 insertions(+), 27 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 6f027bee..85ff53a1 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -2,13 +2,13 @@
<% case item when %> <% when SearchChannel %> - + <% if !env.get("preferences").as(Preferences).thin_mode %>
"/>
<% end %> -

<%= item.author %>

+

<%= HTML.escape(item.author) %>

<%= 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 %> @@ -27,15 +27,13 @@

<%= number_with_separator(item.video_count) %> videos

<% end %> -

<%= item.title %>

+

<%= HTML.escape(item.title) %>

+ + +

<%= HTML.escape(item.author) %>

-

- - <%= item.author %> - -

<% when MixVideo %> - + <% if !env.get("preferences").as(Preferences).thin_mode %>
@@ -44,13 +42,11 @@ <% end %>
<% end %> -

<%= HTML.escape(item.title) %>

+

<%= HTML.escape(item.title) %>

+
+ +

<%= HTML.escape(item.author) %>

-

- - <%= item.author %> - -

<% when PlaylistVideo %> <% if !env.get("preferences").as(Preferences).thin_mode %> @@ -76,13 +72,11 @@ <% end %>
<% end %> -

<%= HTML.escape(item.title) %>

+

<%= HTML.escape(item.title) %>

+ + +

<%= HTML.escape(item.author) %>

-

- - <%= item.author %> - -

<% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> @@ -98,8 +92,8 @@
<% else %> - <% if !env.get("preferences").as(Preferences).thin_mode %> - + + <% if !env.get("preferences").as(Preferences).thin_mode %>
<% if env.get? "show_watched" %> @@ -134,12 +128,13 @@

<%= recode_length_seconds(item.length_seconds) %>

<% end %>
-
- <% end %> -

<%= HTML.escape(item.title) %>

+ <% end %> +

<%= HTML.escape(item.title) %>

+ + -- cgit v1.2.3 From f7992d2d0981d3724fb527b4f07591e1d6db4dc0 Mon Sep 17 00:00:00 2001 From: Penny <82222883+pinchese@users.noreply.github.com> Date: Thu, 24 Jun 2021 23:50:37 -0500 Subject: futureproof comment avatars i was injecting custom css into the site that made the avatars round, and noticed comment avatars looked a little odd i opened dev tools and siffed through the html, and noticed that the image was being padded, when it would look nicer if the element used margin instead of padding with padding: https://imgur.com/c0pB37e with proposed changes (margin instead of padding): https://imgur.com/iKmBzEi --- src/invidious/comments.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 81d6ac2b..21d8b210 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -315,7 +315,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML
- +

-- cgit v1.2.3 From aa55e67389ac8c3786c5e0ac4dca3d50f2193e16 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 25 Jun 2021 07:51:51 -0700 Subject: Fix extraction of age restricted videos --- src/invidious/videos.cr | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 116aafc7..5b2158ec 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -989,9 +989,15 @@ def fetch_video(id, region) # Try to pull streams from embed URL if info["reason"]? - embed_page = YT_POOL.client &.get("/embed/#{id}").body - sts = embed_page.match(/"sts"\s*:\s*(?\d+)/).try &.["sts"]? || "" - embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?html5=1&video_id=#{id}&eurl=https://youtube.googleapis.com/v/#{id}&gl=US&hl=en&sts=#{sts}").body) + required_parameters = URI::Params.new({ + "video_id" => [id], + "eurl" => ["https://youtube.googleapis.com/v/#{id}"], + "html5" => ["1"], + "c" => ["TVHTML5"], + "cver" => ["6.20180913"], + }) + + embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?#{required_parameters}", headers: HTTP::Headers{"x-youtube-client-version" => "6.20180913"}).body) if embed_info["player_response"]? player_response = JSON.parse(embed_info["player_response"]) -- cgit v1.2.3 From ce68d09d2653df27afea6d26d03838ce78b053cb Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Fri, 25 Jun 2021 17:37:37 +0200 Subject: Pick a random video for bypass captcha pick a random video from the 1000 first rows of the channel_videos table in order to bypass the captcha more efficiently --- src/invidious/jobs/bypass_captcha_job.cr | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/jobs/bypass_captcha_job.cr b/src/invidious/jobs/bypass_captcha_job.cr index 87cf7688..71f8a938 100644 --- a/src/invidious/jobs/bypass_captcha_job.cr +++ b/src/invidious/jobs/bypass_captcha_job.cr @@ -2,7 +2,11 @@ class Invidious::Jobs::BypassCaptchaJob < Invidious::Jobs::BaseJob def begin loop do begin - {"/watch?v=zj82_v2R6ts&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: "UCK87Lox575O_HCHBWaBSyGA")}.each do |path| + random_video = PG_DB.query_one?("select id, ucid from (select id, ucid from channel_videos limit 1000) as s ORDER BY RANDOM() LIMIT 1", as: {id: String, ucid: String}) + if !random_video + random_video = {id: "zj82_v2R6ts", ucid: "UCK87Lox575O_HCHBWaBSyGA"} + end + {"/watch?v=#{random_video["id"]}&gl=US&hl=en&has_verified=1&bpctr=9999999999", produce_channel_videos_url(ucid: random_video["ucid"])}.each do |path| response = YT_POOL.client &.get(path) if response.body.includes?("To continue with your YouTube experience, please fill out the form below.") html = XML.parse_html(response.body) -- cgit v1.2.3 From cfcb64c5164816e27496366e0f6fb489dfaa6932 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 25 Jun 2021 20:47:15 +0200 Subject: Fix layout of video 'card' items Previous changes broke alignment of text and icons --- assets/css/default.css | 15 ++++++ src/invidious/views/components/item.ecr | 89 ++++++++++++++++++--------------- 2 files changed, 64 insertions(+), 40 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 1df63412..06a2f07f 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -282,6 +282,21 @@ input[type="search"]::-webkit-search-cancel-button { } } + +/* + * Video "cards" (results/playlist/channel videos) + */ + +.video-card-row { margin: 15px 0; } + +.flexible { display: flex; } +.flex-left { flex: 1 1 100%; flex-wrap: wrap; } +.flex-right { flex: 1 0 max-content; flex-wrap: nowrap; } + +p.channel-name { margin: 0; } +p.video-data { margin: 0; font-weight: bold; font-size: 80%; } + + /* * Footer */ diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 85ff53a1..7fbefc38 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -74,23 +74,28 @@ <% end %>

<%= HTML.escape(item.title) %>

- -

<%= HTML.escape(item.author) %>

-
-
- <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> -
<%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>
- <% elsif Time.utc - item.published > 1.minute %> -
<%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>
- <% else %> -
- <% end %> + + +
+
+ <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> +

<%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>

+ <% elsif Time.utc - item.published > 1.minute %> +

<%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>

+ <% end %> +
-
- <%= item.responds_to?(:views) && item.views ? translate(locale, "`x` views", number_to_short_text(item.views || 0)) : "" %> + <% if item.responds_to?(:views) && item.views %> +
+

<%= translate(locale, "`x` views", number_to_short_text(item.views || 0)) %>

-
+ <% end %> +
<% else %> <% if !env.get("preferences").as(Preferences).thin_mode %> @@ -123,7 +128,7 @@ <% end %> <% if item.responds_to?(:live_now) && item.live_now %> -

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

+

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

<% elsif item.length_seconds != 0 %>

<%= recode_length_seconds(item.length_seconds) %>

<% end %> @@ -132,36 +137,40 @@

<%= HTML.escape(item.title) %>

-
- -

<%= HTML.escape(item.author) %>

-
-
- " href="https://www.youtube.com/watch?v=<%= item.id %>"> - - - " href="/watch?v=<%= item.id %>&listen=1"> - - - " href="/redirect?referer=<%=HTML.escape("watch?v=#{item.id}")%>"> - - + -
- <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> -
<%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>
- <% elsif Time.utc - item.published > 1.minute %> -
<%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>
- <% else %> -
- <% end %> +
+
+ <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> +

<%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>

+ <% elsif Time.utc - item.published > 1.minute %> +

<%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>

+ <% end %> +
-
- <%= item.responds_to?(:views) && item.views ? translate(locale, "`x` views", number_to_short_text(item.views || 0)) : "" %> + <% if item.responds_to?(:views) && item.views %> +
+

<%= translate(locale, "`x` views", number_to_short_text(item.views || 0)) %>

-
+ <% end %> +
<% end %>
-- cgit v1.2.3 From 7da0b2fd7fb977eb9e5f790e63e93b9bde0c9768 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 25 Jun 2021 12:14:21 -0700 Subject: Switch from URI::Params.new to URI::Params.encode --- src/invidious/videos.cr | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 5b2158ec..264a70e8 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -989,15 +989,22 @@ def fetch_video(id, region) # Try to pull streams from embed URL if info["reason"]? - required_parameters = URI::Params.new({ - "video_id" => [id], - "eurl" => ["https://youtube.googleapis.com/v/#{id}"], - "html5" => ["1"], - "c" => ["TVHTML5"], - "cver" => ["6.20180913"], + # The html5, c and cver parameters are required in order to extract age-restricted videos + # See https://github.com/yt-dlp/yt-dlp/commit/4e6767b5f2e2523ebd3dd1240584ead53e8c8905 + required_parameters = URI::Params.encode({ + "video_id" => id, + "eurl" => "https://youtube.googleapis.com/v/#{id}", + "html5" => "1", + "c" => "TVHTML5", + "cver" => "6.20180913", }) - embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?#{required_parameters}", headers: HTTP::Headers{"x-youtube-client-version" => "6.20180913"}).body) + # In order to actually extract video info without error, the `x-youtube-client-version` has to be set to the same version as `cver` above. + embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?#{required_parameters}", + headers: HTTP::Headers{ + "x-youtube-client-version" => "6.20180913", + }).body + ) if embed_info["player_response"]? player_response = JSON.parse(embed_info["player_response"]) -- cgit v1.2.3 From ca4df2967049ca8557506706e384d7ceab3f67a8 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 25 Jun 2021 14:14:41 -0700 Subject: Wrap comment --- src/invidious/videos.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 264a70e8..3e64537e 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -999,7 +999,8 @@ def fetch_video(id, region) "cver" => "6.20180913", }) - # In order to actually extract video info without error, the `x-youtube-client-version` has to be set to the same version as `cver` above. + # In order to actually extract video info without error, the `x-youtube-client-version` + # has to be set to the same version as `cver` above. embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?#{required_parameters}", headers: HTTP::Headers{ "x-youtube-client-version" => "6.20180913", -- cgit v1.2.3 From 54b19a04bb11292634d5275ee25622f048212330 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 27 Jun 2021 07:18:16 -0700 Subject: Fix caption parsing on age restricted videos --- src/invidious.cr | 8 ++++---- src/invidious/routes/embed.cr | 4 ++-- src/invidious/routes/watch.cr | 4 ++-- src/invidious/videos.cr | 31 ++++++++++++++++++------------- src/invidious/views/components/player.ecr | 8 ++++---- src/invidious/views/watch.ecr | 4 ++-- 6 files changed, 32 insertions(+), 27 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index f7c8980a..57809c0b 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -1961,9 +1961,9 @@ get "/api/v1/captions/:id" do |env| json.array do captions.each do |caption| json.object do - json.field "label", caption.name.simpleText + json.field "label", caption.name json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name.simpleText)}" + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" end end end @@ -1979,7 +1979,7 @@ get "/api/v1/captions/:id" do |env| if lang caption = captions.select { |caption| caption.languageCode == lang } else - caption = captions.select { |caption| caption.name.simpleText == label } + caption = captions.select { |caption| caption.name == label } end if caption.empty? @@ -1993,7 +1993,7 @@ get "/api/v1/captions/:id" do |env| # Auto-generated captions often have cues that aren't aligned properly with the video, # as well as some other markup that makes it cumbersome, so we try to fix that here - if caption.name.simpleText.includes? "auto-generated" + if caption.name.includes? "auto-generated" caption_xml = YT_POOL.client &.get(url).body caption_xml = XML.parse(caption_xml) diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 5db32788..5e1e9431 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -165,11 +165,11 @@ class Invidious::Routes::Embed < Invidious::Routes::BaseRoute captions = video.captions preferred_captions = captions.select { |caption| - params.preferred_captions.includes?(caption.name.simpleText) || + params.preferred_captions.includes?(caption.name) || params.preferred_captions.includes?(caption.languageCode.split("-")[0]) } preferred_captions.sort_by! { |caption| - (params.preferred_captions.index(caption.name.simpleText) || + (params.preferred_captions.index(caption.name) || params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil! } captions = captions - preferred_captions diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index d0338882..c6c7c154 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -150,11 +150,11 @@ class Invidious::Routes::Watch < Invidious::Routes::BaseRoute captions = video.captions preferred_captions = captions.select { |caption| - params.preferred_captions.includes?(caption.name.simpleText) || + params.preferred_captions.includes?(caption.name) || params.preferred_captions.includes?(caption.languageCode.split("-")[0]) } preferred_captions.sort_by! { |caption| - (params.preferred_captions.index(caption.name.simpleText) || + (params.preferred_captions.index(caption.name) || params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil! } captions = captions - preferred_captions diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 3e64537e..1c9f5d03 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -425,9 +425,9 @@ struct Video json.array do self.captions.each do |caption| json.object do - json.field "label", caption.name.simpleText + json.field "label", caption.name json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name.simpleText)}" + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" end end end @@ -706,8 +706,12 @@ struct Video def captions : Array(Caption) return @captions.as(Array(Caption)) if @captions captions = info["captions"]?.try &.["playerCaptionsTracklistRenderer"]?.try &.["captionTracks"]?.try &.as_a.map do |caption| - caption = Caption.from_json(caption.to_json) - caption.name.simpleText = caption.name.simpleText.split(" - ")[0] + name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] + languageCode = caption["languageCode"].to_s + baseUrl = caption["baseUrl"].to_s + + caption = Caption.new(name.to_s, languageCode, baseUrl) + caption.name = caption.name.split(" - ")[0] caption end captions ||= [] of Caption @@ -782,18 +786,19 @@ struct Video end end -struct CaptionName - include JSON::Serializable +class Caption + property name + property languageCode + property baseUrl - property simpleText : String -end + getter name : String + getter languageCode : String + getter baseUrl : String -struct Caption - include JSON::Serializable + setter name - property name : CaptionName - property baseUrl : String - property languageCode : String + def initialize(@name, @languageCode, @baseUrl) + end end class VideoRedirect < Exception diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index cff3e60a..c37d20d5 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -25,13 +25,13 @@ <% end %> <% preferred_captions.each do |caption| %> - " - label="<%= caption.name.simpleText %>"> + " + label="<%= caption.name %>"> <% end %> <% captions.each do |caption| %> - " - label="<%= caption.name.simpleText %>"> + " + label="<%= caption.name %>"> <% end %> <% end %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 91e03725..f21fdb04 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -178,8 +178,8 @@ we're going to need to do it here in order to allow for translations. <% end %> <% captions.each do |caption| %> - <% end %> -- cgit v1.2.3 From cf619f24a96820a8a54ec47426dc41164db76bc9 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Thu, 1 Jul 2021 18:13:06 +0200 Subject: Remove workaround for kemalcr/kemal/issues/575 (#2230) Full URL of the issue: https://github.com/kemalcr/kemal/issues/575 --- src/invidious/helpers/helpers.cr | 20 -------------------- 1 file changed, 20 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 7353f2d9..072bdf95 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -700,26 +700,6 @@ def proxy_file(response, env) end end -# See https://github.com/kemalcr/kemal/pull/576 -class HTTP::Server::Response::Output - def close - return if closed? - - unless response.wrote_headers? - response.content_length = @out_count - end - - ensure_headers_written - - super - - if @chunked - @io << "0\r\n\r\n" - @io.flush - end - end -end - class HTTP::Client::Response def pipe(io) HTTP.serialize_body(io, headers, @body, @body_io, @version) -- cgit v1.2.3 From 57bb8c610a5489a7159a29004fdd56ec8bff50e5 Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 1 Jul 2021 23:55:29 -0700 Subject: Use embed stream pull as fallback for gated videos --- src/invidious/videos.cr | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 1c9f5d03..77fa6ecb 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -994,23 +994,33 @@ def fetch_video(id, region) # Try to pull streams from embed URL if info["reason"]? - # The html5, c and cver parameters are required in order to extract age-restricted videos - # See https://github.com/yt-dlp/yt-dlp/commit/4e6767b5f2e2523ebd3dd1240584ead53e8c8905 - required_parameters = URI::Params.encode({ + required_parameters = { "video_id" => id, "eurl" => "https://youtube.googleapis.com/v/#{id}", "html5" => "1", - "c" => "TVHTML5", - "cver" => "6.20180913", - }) - - # In order to actually extract video info without error, the `x-youtube-client-version` - # has to be set to the same version as `cver` above. - embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?#{required_parameters}", - headers: HTTP::Headers{ - "x-youtube-client-version" => "6.20180913", - }).body - ) + "gl" => "US", + "hl" => "en", + } + if info["reason"].as_s.includes?("inappropriate") + # The html5, c and cver parameters are required in order to extract age-restricted videos + # See https://github.com/yt-dlp/yt-dlp/commit/4e6767b5f2e2523ebd3dd1240584ead53e8c8905 + required_parameters.merge!({ + "c" => "TVHTML5", + "cver" => "6.20180913", + }) + + # In order to actually extract video info without error, the `x-youtube-client-version` + # has to be set to the same version as `cver` above. + additional_headers = HTTP::Headers{"x-youtube-client-version" => "6.20180913"} + else + embed_page = YT_POOL.client &.get("/embed/#{id}").body + sts = embed_page.match(/"sts"\s*:\s*(?\d+)/).try &.["sts"]? || "" + required_parameters["sts"] = sts + additional_headers = HTTP::Headers{} of String => String + end + + embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?#{URI::Params.encode(required_parameters)}", + headers: additional_headers).body) if embed_info["player_response"]? player_response = JSON.parse(embed_info["player_response"]) -- cgit v1.2.3 From 39110ad21c2a7a17138d2472942bf52f4540c9f4 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 11 Jul 2021 16:17:22 -0700 Subject: Use struct for caption object --- src/invidious/videos.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 77fa6ecb..27c54b14 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -786,7 +786,7 @@ struct Video end end -class Caption +struct Caption property name property languageCode property baseUrl -- cgit v1.2.3 From c0e8feb66ee27d3376566a88d8c903c74b147128 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 12 Jul 2021 19:41:35 +0200 Subject: Add new and missing locales to i18n.cr New locales: lt, vi Missing: bn_BD, cs, da, eu, hu-HU, si, sk, sr, sr_Cyrl --- src/invidious/helpers/i18n.cr | 65 +++++++++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 27 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index dd46feab..d5e06b25 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,31 +1,42 @@ LOCALES = { - "ar" => load_locale("ar"), - "de" => load_locale("de"), - "el" => load_locale("el"), - "en-US" => load_locale("en-US"), - "eo" => load_locale("eo"), - "es" => load_locale("es"), - "fa" => load_locale("fa"), - "fi" => load_locale("fi"), - "fr" => load_locale("fr"), - "he" => load_locale("he"), - "hr" => load_locale("hr"), - "id" => load_locale("id"), - "is" => load_locale("is"), - "it" => load_locale("it"), - "ja" => load_locale("ja"), - "nb-NO" => load_locale("nb-NO"), - "nl" => load_locale("nl"), - "pl" => load_locale("pl"), - "pt-BR" => load_locale("pt-BR"), - "pt-PT" => load_locale("pt-PT"), - "ro" => load_locale("ro"), - "ru" => load_locale("ru"), - "sv-SE" => load_locale("sv-SE"), - "tr" => load_locale("tr"), - "uk" => load_locale("uk"), - "zh-CN" => load_locale("zh-CN"), - "zh-TW" => load_locale("zh-TW"), + "ar" => load_locale("ar"), # Arabic + "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) + "cs" => load_locale("cs"), # Czech + "da" => load_locale("da"), # Danish + "de" => load_locale("de"), # German + "el" => load_locale("el"), # Greek + "en-US" => load_locale("en-US"), # English (US) + "eo" => load_locale("eo"), # Esperanto + "es" => load_locale("es"), # Spanish + "eu" => load_locale("eu"), # Basque + "fa" => load_locale("fa"), # Persian + "fi" => load_locale("fi"), # Finnish + "fr" => load_locale("fr"), # French + "he" => load_locale("he"), # Hebrew + "hr" => load_locale("hr"), # Croatian + "hu-HU" => load_locale("hu-HU"), # Hungarian + "id" => load_locale("id"), # Indonesian + "is" => load_locale("is"), # Icelandic + "it" => load_locale("it"), # Italian + "ja" => load_locale("ja"), # Japanese + "lt" => load_locale("lt"), # Lithuanian + "nb-NO" => load_locale("nb-NO"), # Norwegian Bokmål + "nl" => load_locale("nl"), # Dutch + "pl" => load_locale("pl"), # Polish + "pt-BR" => load_locale("pt-BR"), # Portuguese (Brazil) + "pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal) + "ro" => load_locale("ro"), # Romanian + "ru" => load_locale("ru"), # Russian + "si" => load_locale("si"), # Sinhala + "sk" => load_locale("sk"), # Slovak + "sr" => load_locale("sr"), # Serbian + "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic) + "sv-SE" => load_locale("sv-SE"), # Swedish + "tr" => load_locale("tr"), # Turkish + "uk" => load_locale("uk"), # Ukrainian + "vi" => load_locale("vi"), # Vietnamese + "zh-CN" => load_locale("zh-CN"), # Chinese (Simplified) + "zh-TW" => load_locale("zh-TW"), # Chinese (Traditional) } def load_locale(name) -- cgit v1.2.3 From ae61662f617fedeecc028817b38a7502cebfdd92 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Wed, 14 Jul 2021 08:46:12 -0700 Subject: Debloat channels.cr into multiple files (#2225) Cherry picked from ui overhaul branch with a few modifications: - channel folder is renamed to channels - parsing for channel home and featured channels are removed due to lack of infrastructure from other commits (cherry picked from commit 44d18b8e147b47ad06a54cc6fd08423d9f39074d) --- spec/helpers_spec.cr | 2 +- src/invidious.cr | 1 + src/invidious/channels.cr | 962 ------------------------------------ src/invidious/channels/about.cr | 192 +++++++ src/invidious/channels/channels.cr | 310 ++++++++++++ src/invidious/channels/community.cr | 275 +++++++++++ src/invidious/channels/playlists.cr | 93 ++++ src/invidious/channels/videos.cr | 89 ++++ 8 files changed, 961 insertions(+), 963 deletions(-) delete mode 100644 src/invidious/channels.cr create mode 100644 src/invidious/channels/about.cr create mode 100644 src/invidious/channels/channels.cr create mode 100644 src/invidious/channels/community.cr create mode 100644 src/invidious/channels/playlists.cr create mode 100644 src/invidious/channels/videos.cr (limited to 'src') diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index ed3a3d48..ada5b28f 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -5,7 +5,7 @@ require "protodec/utils" require "spec" require "yaml" require "../src/invidious/helpers/*" -require "../src/invidious/channels" +require "../src/invidious/channels/*" require "../src/invidious/comments" require "../src/invidious/playlists" require "../src/invidious/search" diff --git a/src/invidious.cr b/src/invidious.cr index f7c8980a..56195632 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -27,6 +27,7 @@ require "compress/zip" require "protodec/utils" require "./invidious/helpers/*" require "./invidious/*" +require "./invidious/channels/*" require "./invidious/routes/**" require "./invidious/jobs/**" diff --git a/src/invidious/channels.cr b/src/invidious/channels.cr deleted file mode 100644 index bbef3d4f..00000000 --- a/src/invidious/channels.cr +++ /dev/null @@ -1,962 +0,0 @@ -struct InvidiousChannel - include DB::Serializable - - property id : String - property author : String - property updated : Time - property deleted : Bool - property subscribed : Time? -end - -struct ChannelVideo - include DB::Serializable - - property id : String - property title : String - property published : Time - property updated : Time - property ucid : String - property author : String - property length_seconds : Int32 = 0 - property live_now : Bool = false - property premiere_timestamp : Time? = nil - property views : Int64? = nil - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "shortVideo" - - json.field "title", self.title - json.field "videoId", self.id - json.field "videoThumbnails" do - generate_thumbnails(json, self.id) - end - - json.field "lengthSeconds", self.length_seconds - - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - json.field "published", self.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) - - json.field "viewCount", self.views - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end - - def to_xml(locale, 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?#{query_params}") - - xml.element("author") do - xml.element("name") { xml.text self.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } - 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?#{query_params}") 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("updated") { xml.text self.updated.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(locale, xml : XML::Builder | Nil = nil) - if xml - to_xml(locale, xml) - else - XML.build do |xml| - to_xml(locale, xml) - end - end - end - - def to_tuple - {% begin %} - { - {{*@type.instance_vars.map { |var| var.name }}} - } - {% end %} - end -end - -struct AboutRelatedChannel - include DB::Serializable - - property ucid : String - property author : String - property author_url : String - property author_thumbnail : String -end - -# TODO: Refactor into either SearchChannel or InvidiousChannel -struct AboutChannel - include DB::Serializable - - property ucid : String - property author : String - property auto_generated : Bool - property author_url : String - property author_thumbnail : String - property banner : String? - property description_html : String - property paid : Bool - property total_views : Int64 - property sub_count : Int32 - property joined : Time - property is_family_friendly : Bool - property allowed_regions : Array(String) - property related_channels : Array(AboutRelatedChannel) - property tabs : Array(String) -end - -class ChannelRedirect < Exception - property channel_id : String - - def initialize(@channel_id) - end -end - -def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) - finished_channel = Channel(String | Nil).new - - spawn do - active_threads = 0 - active_channel = Channel(Nil).new - - channels.each do |ucid| - if active_threads >= max_threads - active_channel.receive - active_threads -= 1 - end - - active_threads += 1 - spawn do - begin - get_channel(ucid, db, refresh, pull_all_videos) - finished_channel.send(ucid) - rescue ex - finished_channel.send(nil) - ensure - active_channel.send(nil) - end - end - end - end - - final = [] of String - channels.size.times do - if ucid = finished_channel.receive - final << ucid - end - end - - return final -end - -def get_channel(id, db, refresh = true, pull_all_videos = true) - if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) - if refresh && Time.utc - channel.updated > 10.minutes - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) - channel_array = channel.to_a - args = arg_array(channel_array) - - db.exec("INSERT INTO channels VALUES (#{args}) \ - ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array) - end - else - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) - channel_array = channel.to_a - args = arg_array(channel_array) - - db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array) - end - - return channel -end - -def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) - LOGGER.debug("fetch_channel: #{ucid}") - LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") - - LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") - rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body - LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed") - rss = XML.parse_html(rss) - - author = rss.xpath_node(%q(//feed/title)) - if !author - raise InfoException.new("Deleted or invalid channel") - end - author = author.content - - # Auto-generated channels - # https://support.google.com/youtube/answer/2579942 - if author.ends_with?(" - Topic") || - {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author - auto_generated = true - end - - LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") - - page = 1 - - LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") - initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - videos = extract_videos(initial_data, author, ucid) - - LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") - rss.xpath_nodes("//feed/entry").each do |entry| - video_id = entry.xpath_node("videoid").not_nil!.content - title = entry.xpath_node("title").not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - author = entry.xpath_node("author/name").not_nil!.content - ucid = entry.xpath_node("channelid").not_nil!.content - views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64? - views ||= 0_i64 - - channel_video = videos.select { |video| video.id == video_id }[0]? - - length_seconds = channel_video.try &.length_seconds - length_seconds ||= 0 - - live_now = channel_video.try &.live_now - live_now ||= false - - premiere_timestamp = channel_video.try &.premiere_timestamp - - video = ChannelVideo.new({ - id: video_id, - title: title, - published: published, - updated: Time.utc, - ucid: ucid, - author: author, - length_seconds: length_seconds, - live_now: live_now, - premiere_timestamp: premiere_timestamp, - views: views, - }) - - LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") - - # We don't include the 'premiere_timestamp' here because channel pages don't include them, - # meaning the above timestamp is always null - was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ - updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - if was_insert - LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) - else - LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") - end - end - - if pull_all_videos - page += 1 - - ids = [] of String - - loop do - initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - videos = extract_videos(initial_data, author, ucid) - - count = videos.size - videos = videos.map { |video| ChannelVideo.new({ - id: video.id, - title: video.title, - published: video.published, - updated: Time.utc, - ucid: video.ucid, - author: video.author, - length_seconds: video.length_seconds, - live_now: video.live_now, - premiere_timestamp: video.premiere_timestamp, - views: video.views, - }) } - - videos.each do |video| - ids << video.id - - # We are notified of Red videos elsewhere (PubSub), which includes a correct published date, - # so since they don't provide a published date here we can safely ignore them. - if Time.utc - video.published > 1.minute - was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ - updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert - end - end - - break if count < 25 - page += 1 - end - end - - channel = InvidiousChannel.new({ - id: ucid, - author: author, - updated: Time.utc, - deleted: false, - subscribed: nil, - }) - - return channel -end - -def fetch_channel_playlists(ucid, author, continuation, sort_by) - if continuation - response_json = request_youtube_api_browse(continuation) - continuationItems = response_json["onResponseReceivedActions"]? - .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - - return [] of SearchItem, nil if !continuationItems - - items = [] of SearchItem - continuationItems.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item| - extract_item(item, author, ucid).try { |t| items << t } - } - - continuation = continuationItems.as_a.last["continuationItemRenderer"]? - .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s - else - url = "/channel/#{ucid}/playlists?flow=list&view=1" - - case sort_by - when "last", "last_added" - # - when "oldest", "oldest_created" - url += "&sort=da" - when "newest", "newest_created" - url += "&sort=dd" - else nil # Ignore - end - - response = YT_POOL.client &.get(url) - initial_data = extract_initial_data(response.body) - return [] of SearchItem, nil if !initial_data - - items = extract_items(initial_data, author, ucid) - continuation = response.body.match(/"token":"(?[^"]+)"/).try &.["continuation"]? - end - - return items, continuation -end - -def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "videos", - "6:varint" => 2_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "13:string" => "", - "23:varint" => 0_i64, - }, - }, - } - - if !v2 - if auto_generated - seed = Time.unix(1525757349) - until seed >= Time.utc - seed += 1.month - end - timestamp = seed - (page - 1).months - - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x36_i64 - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{timestamp.to_unix}" - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{page}" - end - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 - - object["80226972:embedded"]["3:base64"].as(Hash)["61:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ - "1:string" => Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ - "1:varint" => 30_i64 * (page - 1), - }))), - }))) - end - - case sort_by - when "newest" - when "popular" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x01_i64 - when "oldest" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64 - else nil # Ignore - end - - object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) - object["80226972:embedded"].delete("3:base64") - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end - -# Used in bypass_captcha_job.cr -def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) - continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2) - return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" -end - -# ## NOTE: DEPRECATED -# Reason -> Unstable -# The Protobuf object must be provided with an id of the last playlist from the current "page" -# in order to fetch the next one accurately -# (if the id isn't included, entries shift around erratically between pages, -# leading to repetitions and skip overs) -# -# Since it's impossible to produce the appropriate Protobuf without an id being provided by the user, -# it's better to stick to continuation tokens provided by the first request and onward -def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "playlists", - "6:varint" => 2_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "13:string" => "", - "23:varint" => 0_i64, - }, - }, - } - - if cursor - cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor - end - - if auto_generated - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64 - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 1_i64 - case sort - when "oldest", "oldest_created" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 2_i64 - when "newest", "newest_created" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64 - when "last", "last_added" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64 - else nil # Ignore - end - end - - object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) - object["80226972:embedded"].delete("3:base64") - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" -end - -# TODO: Add "sort_by" -def fetch_channel_community(ucid, continuation, locale, format, thin_mode) - response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") - if response.status_code != 200 - response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en") - end - - if response.status_code != 200 - raise InfoException.new("This channel does not exist.") - end - - ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"] - - if !continuation || continuation.empty? - initial_data = extract_initial_data(response.body) - body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]? - - if !body - raise InfoException.new("Could not extract community tab.") - end - - body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"] - else - continuation = produce_channel_community_continuation(ucid, continuation) - - headers = HTTP::Headers.new - headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"] - - session_token = response.body.match(/"XSRF_TOKEN":"(?[^"]+)"/).try &.["session_token"]? || "" - post_req = { - session_token: session_token, - } - - response = YT_POOL.client &.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req) - body = JSON.parse(response.body) - - body = body["response"]["continuationContents"]["itemSectionContinuation"]? || - body["response"]["continuationContents"]["backstageCommentsContinuation"]? - - if !body - raise InfoException.new("Could not extract continuation.") - end - end - - continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s - posts = body["contents"].as_a - - if message = posts[0]["messageRenderer"]? - error_message = (message["text"]["simpleText"]? || - message["text"]["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s || "" - raise InfoException.new(error_message) - end - - response = JSON.build do |json| - json.object do - json.field "authorId", ucid - json.field "comments" do - json.array do - posts.each do |post| - comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? || - post["backstageCommentsContinuation"]? - - post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? || - post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]? - - next if !post - - content_html = post["contentText"]?.try { |t| parse_content(t) } || "" - author = post["authorText"]?.try &.["simpleText"]? || "" - - json.object do - json.field "author", author - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s - - qualities.each do |quality| - json.object do - json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-") - json.field "width", quality - json.field "height", quality - end - end - end - end - - if post["authorEndpoint"]? - json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s - else - json.field "authorId", "" - json.field "authorUrl", "" - end - - published_text = post["publishedTimeText"]["runs"][0]["text"].as_s - published = decode_date(published_text.rchop(" (edited)")) - - if published_text.includes?(" (edited)") - json.field "isEdited", true - else - json.field "isEdited", false - end - - like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"] - .try &.as_s.gsub(/\D/, "").to_i? || 0 - - json.field "content", html_to_content(content_html) - json.field "contentHtml", content_html - - json.field "published", published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) - - json.field "likeCount", like_count - json.field "commentId", post["postId"]? || post["commentId"]? || "" - json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid - - if attachment = post["backstageAttachment"]? - json.field "attachment" do - json.object do - case attachment.as_h - when .has_key?("videoRenderer") - attachment = attachment["videoRenderer"] - json.field "type", "video" - - if !attachment["videoId"]? - error_message = (attachment["title"]["simpleText"]? || - attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?) - - json.field "error", error_message - else - video_id = attachment["videoId"].as_s - - video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]? - json.field "title", video_title - json.field "videoId", video_id - json.field "videoThumbnails" do - generate_thumbnails(json, video_id) - end - - json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) - - author_info = attachment["ownerText"]["runs"][0].as_h - - json.field "author", author_info["text"].as_s - json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"] - - # TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers" - # TODO: json.field "authorVerified", "ownerBadges" - - published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s) - - json.field "published", published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) - - view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64 - - json.field "viewCount", view_count - json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count)) - end - when .has_key?("backstageImageRenderer") - attachment = attachment["backstageImageRenderer"] - json.field "type", "image" - - json.field "imageThumbnails" do - json.array do - thumbnail = attachment["image"]["thumbnails"][0].as_h - width = thumbnail["width"].as_i - height = thumbnail["height"].as_i - aspect_ratio = (width.to_f / height.to_f) - url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - - qualities = {320, 560, 640, 1280, 2000} - - qualities.each do |quality| - json.object do - json.field "url", url.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", (quality / aspect_ratio).ceil.to_i - end - end - end - end - # TODO - # when .has_key?("pollRenderer") - # attachment = attachment["pollRenderer"] - # json.field "type", "poll" - else - json.field "type", "unknown" - json.field "error", "Unrecognized attachment type." - end - end - end - end - - if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? || - comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s.gsub(/\D/, "").to_i?) - continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s - continuation ||= "" - - json.field "replies" do - json.object do - json.field "replyCount", reply_count - json.field "continuation", extract_channel_community_cursor(continuation) - end - end - end - end - end - end - end - - if body["continuations"]? - continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s - json.field "continuation", extract_channel_community_cursor(continuation) - end - end - end - - if format == "html" - response = JSON.parse(response) - content_html = template_youtube_comments(response, locale, thin_mode) - - response = JSON.build do |json| - json.object do - json.field "contentHtml", content_html - end - end - end - - return response -end - -def produce_channel_community_continuation(ucid, cursor) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:string" => cursor || "", - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(object) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end - -def extract_channel_community_cursor(continuation) - object = URI.decode_www_form(continuation) - .try { |i| Base64.decode(i) } - .try { |i| IO::Memory.new(i) } - .try { |i| Protodec::Any.parse(i) } - .try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h } - - if object["53:2:embedded"]?.try &.["3:0:embedded"]? - object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"] - .try { |i| i["2:0:base64"].as_h } - .try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i, padding: false) } - - object["53:2:embedded"]["3:0:embedded"].as_h.delete("2:0:base64") - end - - cursor = Protodec::Any.cast_json(object) - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - - cursor -end - -def get_about_info(ucid, locale) - result = YT_POOL.client &.get("/channel/#{ucid}/about?gl=US&hl=en") - if result.status_code != 200 - result = YT_POOL.client &.get("/user/#{ucid}/about?gl=US&hl=en") - end - - if md = result.headers["location"]?.try &.match(/\/channel\/(?UC[a-zA-Z0-9_-]{22})/) - raise ChannelRedirect.new(channel_id: md["ucid"]) - end - - if result.status_code != 200 - raise InfoException.new("This channel does not exist.") - end - - about = XML.parse_html(result.body) - if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")])) - raise InfoException.new("This channel does not exist.") - end - - initdata = extract_initial_data(result.body) - if initdata.empty? - error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip - error_message ||= translate(locale, "Could not get channel info.") - raise InfoException.new(error_message) - end - - if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]? - raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s) - end - - auto_generated = false - # Check for special auto generated gaming channels - if !initdata.has_key?("metadata") - auto_generated = true - end - - if auto_generated - author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s - author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s - author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s - - # Raises a KeyError on failure. - banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banner = banners.try &.[-1]?.try &.["url"].as_s? - - description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s - description_html = HTML.escape(description).gsub("\n", "
") - - paid = false - is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool - allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s } - - related_channels = [] of AboutRelatedChannel - else - author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s - author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s - author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s - - ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s - - # Raises a KeyError on failure. - banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banner = banners.try &.[-1]?.try &.["url"].as_s? - - # if banner.includes? "channels/c4/default_banner" - # banner = nil - # end - - description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" - description_html = HTML.escape(description).gsub("\n", "
") - - paid = about.xpath_node(%q(//meta[@itemprop="paid"])).not_nil!["content"] == "True" - is_family_friendly = about.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True" - allowed_regions = about.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",") - - related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"] - .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]? - .try &.["verticalChannelSectionRenderer"]?.try &.["items"]?.try &.as_a.map do |node| - renderer = node["miniChannelRenderer"]? - related_id = renderer.try &.["channelId"]?.try &.as_s? - related_id ||= "" - - related_title = renderer.try &.["title"]?.try &.["simpleText"]?.try &.as_s? - related_title ||= "" - - related_author_url = renderer.try &.["navigationEndpoint"]?.try &.["commandMetadata"]?.try &.["webCommandMetadata"]? - .try &.["url"]?.try &.as_s? - related_author_url ||= "" - - related_author_thumbnails = renderer.try &.["thumbnail"]?.try &.["thumbnails"]?.try &.as_a? - related_author_thumbnails ||= [] of JSON::Any - - related_author_thumbnail = "" - if related_author_thumbnails.size > 0 - related_author_thumbnail = related_author_thumbnails[-1]["url"]?.try &.as_s? - related_author_thumbnail ||= "" - end - - AboutRelatedChannel.new({ - ucid: related_id, - author: related_title, - author_url: related_author_url, - author_thumbnail: related_author_thumbnail, - }) - end - related_channels ||= [] of AboutRelatedChannel - end - - total_views = 0_i64 - joined = Time.unix(0) - tabs = [] of String - - tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?.try &.as_a? - if !tabs_json.nil? - # Retrieve information from the tabs array. The index we are looking for varies between channels. - tabs_json.each do |node| - # Try to find the about section which is located in only one of the tabs. - channel_about_meta = node["tabRenderer"]?.try &.["content"]?.try &.["sectionListRenderer"]? - .try &.["contents"]?.try &.[0]?.try &.["itemSectionRenderer"]?.try &.["contents"]? - .try &.[0]?.try &.["channelAboutFullMetadataRenderer"]? - - if !channel_about_meta.nil? - total_views = channel_about_meta["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 - - # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. - joined = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, node| acc + node["text"].as_s } - .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) - - # Normal Auto-generated channels - # https://support.google.com/youtube/answer/2579942 - # For auto-generated channels, channel_about_meta only has ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] - if (channel_about_meta["primaryLinks"]?.try &.size || 0) == 1 && (channel_about_meta["primaryLinks"][0]?) && - (channel_about_meta["primaryLinks"][0]["title"]?.try &.["simpleText"]?.try &.as_s? || "") == "Auto-generated by YouTube" - auto_generated = true - end - end - end - tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map { |node| node["tabRenderer"]["title"].as_s.downcase } - end - - sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? - .try { |text| short_text_to_number(text.split(" ")[0]) } || 0 - - AboutChannel.new({ - ucid: ucid, - author: author, - auto_generated: auto_generated, - author_url: author_url, - author_thumbnail: author_thumbnail, - banner: banner, - description_html: description_html, - paid: paid, - total_views: total_views, - sub_count: sub_count, - joined: joined, - is_family_friendly: is_family_friendly, - allowed_regions: allowed_regions, - related_channels: related_channels, - tabs: tabs, - }) -end - -def get_channel_videos_response(ucid, page = 1, auto_generated = nil, sort_by = "newest") - continuation = produce_channel_videos_continuation(ucid, page, - auto_generated: auto_generated, sort_by: sort_by, v2: true) - - return request_youtube_api_browse(continuation) -end - -def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") - videos = [] of SearchVideo - - 2.times do |i| - initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) - videos.concat extract_videos(initial_data, author, ucid) - end - - return videos.size, videos -end - -def get_latest_videos(ucid) - initial_data = get_channel_videos_response(ucid) - author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s - - return extract_videos(initial_data, author, ucid) -end diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr new file mode 100644 index 00000000..8b0ecfbc --- /dev/null +++ b/src/invidious/channels/about.cr @@ -0,0 +1,192 @@ +# TODO: Refactor into either SearchChannel or InvidiousChannel +struct AboutChannel + include DB::Serializable + + property ucid : String + property author : String + property auto_generated : Bool + property author_url : String + property author_thumbnail : String + property banner : String? + property description_html : String + property paid : Bool + property total_views : Int64 + property sub_count : Int32 + property joined : Time + property is_family_friendly : Bool + property allowed_regions : Array(String) + property related_channels : Array(AboutRelatedChannel) + property tabs : Array(String) +end + +struct AboutRelatedChannel + include DB::Serializable + + property ucid : String + property author : String + property author_url : String + property author_thumbnail : String +end + +def get_about_info(ucid, locale) + result = YT_POOL.client &.get("/channel/#{ucid}/about?gl=US&hl=en") + if result.status_code != 200 + result = YT_POOL.client &.get("/user/#{ucid}/about?gl=US&hl=en") + end + + if md = result.headers["location"]?.try &.match(/\/channel\/(?UC[a-zA-Z0-9_-]{22})/) + raise ChannelRedirect.new(channel_id: md["ucid"]) + end + + if result.status_code != 200 + raise InfoException.new("This channel does not exist.") + end + + about = XML.parse_html(result.body) + if about.xpath_node(%q(//div[contains(@class, "channel-empty-message")])) + raise InfoException.new("This channel does not exist.") + end + + initdata = extract_initial_data(result.body) + if initdata.empty? + error_message = about.xpath_node(%q(//div[@class="yt-alert-content"])).try &.content.strip + error_message ||= translate(locale, "Could not get channel info.") + raise InfoException.new(error_message) + end + + if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]? + raise ChannelRedirect.new(channel_id: browse_endpoint["browseId"].to_s) + end + + auto_generated = false + # Check for special auto generated gaming channels + if !initdata.has_key?("metadata") + auto_generated = true + end + + if auto_generated + author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s + author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s + author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s + + # Raises a KeyError on failure. + banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? + banner = banners.try &.[-1]?.try &.["url"].as_s? + + description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s + description_html = HTML.escape(description).gsub("\n", "
") + + paid = false + is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool + allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s } + + related_channels = [] of AboutRelatedChannel + else + author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s + author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s + author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s + + ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s + + # Raises a KeyError on failure. + banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? + banner = banners.try &.[-1]?.try &.["url"].as_s? + + # if banner.includes? "channels/c4/default_banner" + # banner = nil + # end + + description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" + description_html = HTML.escape(description).gsub("\n", "
") + + paid = about.xpath_node(%q(//meta[@itemprop="paid"])).not_nil!["content"] == "True" + is_family_friendly = about.xpath_node(%q(//meta[@itemprop="isFamilyFriendly"])).not_nil!["content"] == "True" + allowed_regions = about.xpath_node(%q(//meta[@itemprop="regionsAllowed"])).not_nil!["content"].split(",") + + related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"] + .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]? + .try &.["verticalChannelSectionRenderer"]?.try &.["items"]?.try &.as_a.map do |node| + renderer = node["miniChannelRenderer"]? + related_id = renderer.try &.["channelId"]?.try &.as_s? + related_id ||= "" + + related_title = renderer.try &.["title"]?.try &.["simpleText"]?.try &.as_s? + related_title ||= "" + + related_author_url = renderer.try &.["navigationEndpoint"]?.try &.["commandMetadata"]?.try &.["webCommandMetadata"]? + .try &.["url"]?.try &.as_s? + related_author_url ||= "" + + related_author_thumbnails = renderer.try &.["thumbnail"]?.try &.["thumbnails"]?.try &.as_a? + related_author_thumbnails ||= [] of JSON::Any + + related_author_thumbnail = "" + if related_author_thumbnails.size > 0 + related_author_thumbnail = related_author_thumbnails[-1]["url"]?.try &.as_s? + related_author_thumbnail ||= "" + end + + AboutRelatedChannel.new({ + ucid: related_id, + author: related_title, + author_url: related_author_url, + author_thumbnail: related_author_thumbnail, + }) + end + related_channels ||= [] of AboutRelatedChannel + end + + total_views = 0_i64 + joined = Time.unix(0) + + tabs = [] of String + + tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?.try &.as_a? + if !tabs_json.nil? + # Retrieve information from the tabs array. The index we are looking for varies between channels. + tabs_json.each do |node| + # Try to find the about section which is located in only one of the tabs. + channel_about_meta = node["tabRenderer"]?.try &.["content"]?.try &.["sectionListRenderer"]? + .try &.["contents"]?.try &.[0]?.try &.["itemSectionRenderer"]?.try &.["contents"]? + .try &.[0]?.try &.["channelAboutFullMetadataRenderer"]? + + if !channel_about_meta.nil? + total_views = channel_about_meta["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 + + # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. + joined = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, node| acc + node["text"].as_s } + .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) + + # Normal Auto-generated channels + # https://support.google.com/youtube/answer/2579942 + # For auto-generated channels, channel_about_meta only has ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] + if (channel_about_meta["primaryLinks"]?.try &.size || 0) == 1 && (channel_about_meta["primaryLinks"][0]?) && + (channel_about_meta["primaryLinks"][0]["title"]?.try &.["simpleText"]?.try &.as_s? || "") == "Auto-generated by YouTube" + auto_generated = true + end + end + end + tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map { |node| node["tabRenderer"]["title"].as_s.downcase } + end + + sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? + .try { |text| short_text_to_number(text.split(" ")[0]) } || 0 + + AboutChannel.new({ + ucid: ucid, + author: author, + auto_generated: auto_generated, + author_url: author_url, + author_thumbnail: author_thumbnail, + banner: banner, + description_html: description_html, + paid: paid, + total_views: total_views, + sub_count: sub_count, + joined: joined, + is_family_friendly: is_family_friendly, + allowed_regions: allowed_regions, + related_channels: related_channels, + tabs: tabs, + }) +end diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr new file mode 100644 index 00000000..a6ab4015 --- /dev/null +++ b/src/invidious/channels/channels.cr @@ -0,0 +1,310 @@ +struct InvidiousChannel + include DB::Serializable + + property id : String + property author : String + property updated : Time + property deleted : Bool + property subscribed : Time? +end + +struct ChannelVideo + include DB::Serializable + + property id : String + property title : String + property published : Time + property updated : Time + property ucid : String + property author : String + property length_seconds : Int32 = 0 + property live_now : Bool = false + property premiere_timestamp : Time? = nil + property views : Int64? = nil + + def to_json(locale, json : JSON::Builder) + json.object do + json.field "type", "shortVideo" + + json.field "title", self.title + json.field "videoId", self.id + json.field "videoThumbnails" do + generate_thumbnails(json, self.id) + end + + json.field "lengthSeconds", self.length_seconds + + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + json.field "published", self.published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) + + json.field "viewCount", self.views + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end + + def to_xml(locale, 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?#{query_params}") + + xml.element("author") do + xml.element("name") { xml.text self.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{self.ucid}" } + 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?#{query_params}") 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("updated") { xml.text self.updated.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(locale, xml : XML::Builder | Nil = nil) + if xml + to_xml(locale, xml) + else + XML.build do |xml| + to_xml(locale, xml) + end + end + end + + def to_tuple + {% begin %} + { + {{*@type.instance_vars.map { |var| var.name }}} + } + {% end %} + end +end + +class ChannelRedirect < Exception + property channel_id : String + + def initialize(@channel_id) + end +end + +def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) + finished_channel = Channel(String | Nil).new + + spawn do + active_threads = 0 + active_channel = Channel(Nil).new + + channels.each do |ucid| + if active_threads >= max_threads + active_channel.receive + active_threads -= 1 + end + + active_threads += 1 + spawn do + begin + get_channel(ucid, db, refresh, pull_all_videos) + finished_channel.send(ucid) + rescue ex + finished_channel.send(nil) + ensure + active_channel.send(nil) + end + end + end + end + + final = [] of String + channels.size.times do + if ucid = finished_channel.receive + final << ucid + end + end + + return final +end + +def get_channel(id, db, refresh = true, pull_all_videos = true) + if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) + if refresh && Time.utc - channel.updated > 10.minutes + channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) + channel_array = channel.to_a + args = arg_array(channel_array) + + db.exec("INSERT INTO channels VALUES (#{args}) \ + ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array) + end + else + channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) + channel_array = channel.to_a + args = arg_array(channel_array) + + db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array) + end + + return channel +end + +def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) + LOGGER.debug("fetch_channel: #{ucid}") + LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") + + LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") + rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body + LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed") + rss = XML.parse_html(rss) + + author = rss.xpath_node(%q(//feed/title)) + if !author + raise InfoException.new("Deleted or invalid channel") + end + author = author.content + + # Auto-generated channels + # https://support.google.com/youtube/answer/2579942 + if author.ends_with?(" - Topic") || + {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? author + auto_generated = true + end + + LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") + + page = 1 + + LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") + initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) + videos = extract_videos(initial_data, author, ucid) + + LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") + rss.xpath_nodes("//feed/entry").each do |entry| + video_id = entry.xpath_node("videoid").not_nil!.content + title = entry.xpath_node("title").not_nil!.content + published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) + author = entry.xpath_node("author/name").not_nil!.content + ucid = entry.xpath_node("channelid").not_nil!.content + views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64? + views ||= 0_i64 + + channel_video = videos.select { |video| video.id == video_id }[0]? + + length_seconds = channel_video.try &.length_seconds + length_seconds ||= 0 + + live_now = channel_video.try &.live_now + live_now ||= false + + premiere_timestamp = channel_video.try &.premiere_timestamp + + video = ChannelVideo.new({ + id: video_id, + title: title, + published: published, + updated: Time.utc, + ucid: ucid, + author: author, + length_seconds: length_seconds, + live_now: live_now, + premiere_timestamp: premiere_timestamp, + views: views, + }) + + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") + + # We don't include the 'premiere_timestamp' here because channel pages don't include them, + # meaning the above timestamp is always null + was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ + ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ + updated = $4, ucid = $5, author = $6, length_seconds = $7, \ + live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) + + if was_insert + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") + db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ + feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) + else + LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") + end + end + + if pull_all_videos + page += 1 + + ids = [] of String + + loop do + initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) + videos = extract_videos(initial_data, author, ucid) + + count = videos.size + videos = videos.map { |video| ChannelVideo.new({ + id: video.id, + title: video.title, + published: video.published, + updated: Time.utc, + ucid: video.ucid, + author: video.author, + length_seconds: video.length_seconds, + live_now: video.live_now, + premiere_timestamp: video.premiere_timestamp, + views: video.views, + }) } + + videos.each do |video| + ids << video.id + + # We are notified of Red videos elsewhere (PubSub), which includes a correct published date, + # so since they don't provide a published date here we can safely ignore them. + if Time.utc - video.published > 1.minute + was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ + ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ + updated = $4, ucid = $5, author = $6, length_seconds = $7, \ + live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) + + db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ + feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert + end + end + + break if count < 25 + page += 1 + end + end + + channel = InvidiousChannel.new({ + id: ucid, + author: author, + updated: Time.utc, + deleted: false, + subscribed: nil, + }) + + return channel +end diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr new file mode 100644 index 00000000..97ab30ec --- /dev/null +++ b/src/invidious/channels/community.cr @@ -0,0 +1,275 @@ +# TODO: Add "sort_by" +def fetch_channel_community(ucid, continuation, locale, format, thin_mode) + response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") + if response.status_code != 200 + response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en") + end + + if response.status_code != 200 + raise InfoException.new("This channel does not exist.") + end + + ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"] + + if !continuation || continuation.empty? + initial_data = extract_initial_data(response.body) + body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]? + + if !body + raise InfoException.new("Could not extract community tab.") + end + + body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"] + else + continuation = produce_channel_community_continuation(ucid, continuation) + + headers = HTTP::Headers.new + headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"] + + session_token = response.body.match(/"XSRF_TOKEN":"(?[^"]+)"/).try &.["session_token"]? || "" + post_req = { + session_token: session_token, + } + + response = YT_POOL.client &.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req) + body = JSON.parse(response.body) + + body = body["response"]["continuationContents"]["itemSectionContinuation"]? || + body["response"]["continuationContents"]["backstageCommentsContinuation"]? + + if !body + raise InfoException.new("Could not extract continuation.") + end + end + + continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s + posts = body["contents"].as_a + + if message = posts[0]["messageRenderer"]? + error_message = (message["text"]["simpleText"]? || + message["text"]["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s || "" + raise InfoException.new(error_message) + end + + response = JSON.build do |json| + json.object do + json.field "authorId", ucid + json.field "comments" do + json.array do + posts.each do |post| + comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? || + post["backstageCommentsContinuation"]? + + post = post["backstagePostThreadRenderer"]?.try &.["post"]["backstagePostRenderer"]? || + post["commentThreadRenderer"]?.try &.["comment"]["commentRenderer"]? + + next if !post + + content_html = post["contentText"]?.try { |t| parse_content(t) } || "" + author = post["authorText"]?.try &.["simpleText"]? || "" + + json.object do + json.field "author", author + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s + + qualities.each do |quality| + json.object do + json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-") + json.field "width", quality + json.field "height", quality + end + end + end + end + + if post["authorEndpoint"]? + json.field "authorId", post["authorEndpoint"]["browseEndpoint"]["browseId"] + json.field "authorUrl", post["authorEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"].as_s + else + json.field "authorId", "" + json.field "authorUrl", "" + end + + published_text = post["publishedTimeText"]["runs"][0]["text"].as_s + published = decode_date(published_text.rchop(" (edited)")) + + if published_text.includes?(" (edited)") + json.field "isEdited", true + else + json.field "isEdited", false + end + + like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"] + .try &.as_s.gsub(/\D/, "").to_i? || 0 + + json.field "content", html_to_content(content_html) + json.field "contentHtml", content_html + + json.field "published", published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) + + json.field "likeCount", like_count + json.field "commentId", post["postId"]? || post["commentId"]? || "" + json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid + + if attachment = post["backstageAttachment"]? + json.field "attachment" do + json.object do + case attachment.as_h + when .has_key?("videoRenderer") + attachment = attachment["videoRenderer"] + json.field "type", "video" + + if !attachment["videoId"]? + error_message = (attachment["title"]["simpleText"]? || + attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?) + + json.field "error", error_message + else + video_id = attachment["videoId"].as_s + + video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]? + json.field "title", video_title + json.field "videoId", video_id + json.field "videoThumbnails" do + generate_thumbnails(json, video_id) + end + + json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) + + author_info = attachment["ownerText"]["runs"][0].as_h + + json.field "author", author_info["text"].as_s + json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"] + json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"] + + # TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers" + # TODO: json.field "authorVerified", "ownerBadges" + + published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s) + + json.field "published", published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) + + view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64 + + json.field "viewCount", view_count + json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count)) + end + when .has_key?("backstageImageRenderer") + attachment = attachment["backstageImageRenderer"] + json.field "type", "image" + + json.field "imageThumbnails" do + json.array do + thumbnail = attachment["image"]["thumbnails"][0].as_h + width = thumbnail["width"].as_i + height = thumbnail["height"].as_i + aspect_ratio = (width.to_f / height.to_f) + url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") + + qualities = {320, 560, 640, 1280, 2000} + + qualities.each do |quality| + json.object do + json.field "url", url.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", (quality / aspect_ratio).ceil.to_i + end + end + end + end + # TODO + # when .has_key?("pollRenderer") + # attachment = attachment["pollRenderer"] + # json.field "type", "poll" + else + json.field "type", "unknown" + json.field "error", "Unrecognized attachment type." + end + end + end + end + + if comments && (reply_count = (comments["backstageCommentsRenderer"]["moreText"]["simpleText"]? || + comments["backstageCommentsRenderer"]["moreText"]["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s.gsub(/\D/, "").to_i?) + continuation = comments["backstageCommentsRenderer"]["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s + continuation ||= "" + + json.field "replies" do + json.object do + json.field "replyCount", reply_count + json.field "continuation", extract_channel_community_cursor(continuation) + end + end + end + end + end + end + end + + if body["continuations"]? + continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s + json.field "continuation", extract_channel_community_cursor(continuation) + end + end + end + + if format == "html" + response = JSON.parse(response) + content_html = template_youtube_comments(response, locale, thin_mode) + + response = JSON.build do |json| + json.object do + json.field "contentHtml", content_html + end + end + end + + return response +end + +def produce_channel_community_continuation(ucid, cursor) + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:string" => cursor || "", + }, + } + + continuation = object.try { |i| Protodec::Any.cast_json(object) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation +end + +def extract_channel_community_cursor(continuation) + object = URI.decode_www_form(continuation) + .try { |i| Base64.decode(i) } + .try { |i| IO::Memory.new(i) } + .try { |i| Protodec::Any.parse(i) } + .try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h } + + if object["53:2:embedded"]?.try &.["3:0:embedded"]? + object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"] + .try { |i| i["2:0:base64"].as_h } + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i, padding: false) } + + object["53:2:embedded"]["3:0:embedded"].as_h.delete("2:0:base64") + end + + cursor = Protodec::Any.cast_json(object) + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + + cursor +end diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr new file mode 100644 index 00000000..222ec2b1 --- /dev/null +++ b/src/invidious/channels/playlists.cr @@ -0,0 +1,93 @@ +def fetch_channel_playlists(ucid, author, continuation, sort_by) + if continuation + response_json = request_youtube_api_browse(continuation) + continuationItems = response_json["onResponseReceivedActions"]? + .try &.[0]["appendContinuationItemsAction"]["continuationItems"] + + return [] of SearchItem, nil if !continuationItems + + items = [] of SearchItem + continuationItems.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item| + extract_item(item, author, ucid).try { |t| items << t } + } + + continuation = continuationItems.as_a.last["continuationItemRenderer"]? + .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s + else + url = "/channel/#{ucid}/playlists?flow=list&view=1" + + case sort_by + when "last", "last_added" + # + when "oldest", "oldest_created" + url += "&sort=da" + when "newest", "newest_created" + url += "&sort=dd" + else nil # Ignore + end + + response = YT_POOL.client &.get(url) + initial_data = extract_initial_data(response.body) + return [] of SearchItem, nil if !initial_data + + items = extract_items(initial_data, author, ucid) + continuation = response.body.match(/"token":"(?[^"]+)"/).try &.["continuation"]? + end + + return items, continuation +end + +# ## NOTE: DEPRECATED +# Reason -> Unstable +# The Protobuf object must be provided with an id of the last playlist from the current "page" +# in order to fetch the next one accurately +# (if the id isn't included, entries shift around erratically between pages, +# leading to repetitions and skip overs) +# +# Since it's impossible to produce the appropriate Protobuf without an id being provided by the user, +# it's better to stick to continuation tokens provided by the first request and onward +def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false) + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:base64" => { + "2:string" => "playlists", + "6:varint" => 2_i64, + "7:varint" => 1_i64, + "12:varint" => 1_i64, + "13:string" => "", + "23:varint" => 0_i64, + }, + }, + } + + if cursor + cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated + object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor + end + + if auto_generated + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64 + else + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 1_i64 + case sort + when "oldest", "oldest_created" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 2_i64 + when "newest", "newest_created" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64 + when "last", "last_added" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64 + else nil # Ignore + end + end + + object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) + object["80226972:embedded"].delete("3:base64") + + continuation = object.try { |i| Protodec::Any.cast_json(object) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" +end diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr new file mode 100644 index 00000000..cc291e9e --- /dev/null +++ b/src/invidious/channels/videos.cr @@ -0,0 +1,89 @@ +def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:base64" => { + "2:string" => "videos", + "6:varint" => 2_i64, + "7:varint" => 1_i64, + "12:varint" => 1_i64, + "13:string" => "", + "23:varint" => 0_i64, + }, + }, + } + + if !v2 + if auto_generated + seed = Time.unix(1525757349) + until seed >= Time.utc + seed += 1.month + end + timestamp = seed - (page - 1).months + + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x36_i64 + object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{timestamp.to_unix}" + else + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 + object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{page}" + end + else + object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 + + object["80226972:embedded"]["3:base64"].as(Hash)["61:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ + "1:string" => Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ + "1:varint" => 30_i64 * (page - 1), + }))), + }))) + end + + case sort_by + when "newest" + when "popular" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x01_i64 + when "oldest" + object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64 + else nil # Ignore + end + + object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) + object["80226972:embedded"].delete("3:base64") + + continuation = object.try { |i| Protodec::Any.cast_json(object) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation +end + +def get_channel_videos_response(ucid, page = 1, auto_generated = nil, sort_by = "newest") + continuation = produce_channel_videos_continuation(ucid, page, + auto_generated: auto_generated, sort_by: sort_by, v2: true) + + return request_youtube_api_browse(continuation) +end + +def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") + videos = [] of SearchVideo + + 2.times do |i| + initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) + videos.concat extract_videos(initial_data, author, ucid) + end + + return videos.size, videos +end + +def get_latest_videos(ucid) + initial_data = get_channel_videos_response(ucid) + author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s + + return extract_videos(initial_data, author, ucid) +end + +# Used in bypass_captcha_job.cr +def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) + continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2) + return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" +end -- cgit v1.2.3 From 0d57a887ea59b6111d9a19b1ea1cea619ad9f129 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Wed, 14 Jul 2021 17:59:33 +0200 Subject: Mute unbuffered_flush IOError exception (#2235) Related to #1416, it doesn't really fix the real error, but instead mutes the exception message. Like explained in #1416, this "exception Error" while flushing the client data doesn't harm the client-server connection. However, this exception message continuously spams the logs and makes debugging and error finding really difficult. --- src/invidious/helpers/helpers.cr | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'src') diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 072bdf95..d332ad37 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -700,6 +700,16 @@ def proxy_file(response, env) end end +class HTTP::Server::Response + class Output + private def unbuffered_flush + @io.flush + rescue ex : IO::Error + unbuffered_close + end + end +end + class HTTP::Client::Response def pipe(io) HTTP.serialize_body(io, headers, @body, @body_io, @version) -- cgit v1.2.3 From 56ebef4352c9effd89c8e4b5737f2479bd0b0ae5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 15 Jul 2021 23:01:36 +0200 Subject: Multiple front-end fixes (#2247) Fixes: * Sanitize user-provided content in HTML (Fixes #2193) * Fix encoding of search query in prev/next pages (Fixes #2229) * Fix some issues introduced with #2196: - Fix alignment of all

elements (Move the inline style from the parent to the

element) - Add missing comma on 'dir' HTML attribute (Typo introduced by PR #2196) Code cleaning: * Remove unnecessary 'each_sclice' + 'each' double loop in ECR files * Clean the player's list generation code (in player.ecr) --- src/invidious/comments.cr | 4 ++- src/invidious/views/authorize_token.ecr | 8 ++--- src/invidious/views/channel.ecr | 35 +++++++++--------- src/invidious/views/community.ecr | 11 +++--- src/invidious/views/components/item.ecr | 8 ++--- src/invidious/views/components/player.ecr | 19 ++++++---- src/invidious/views/edit_playlist.ecr | 16 ++++----- src/invidious/views/history.ecr | 54 +++++++++++++--------------- src/invidious/views/login.ecr | 4 +-- src/invidious/views/mix.ecr | 16 ++++----- src/invidious/views/playlist.ecr | 23 ++++++------ src/invidious/views/playlists.ecr | 29 ++++++++------- src/invidious/views/popular.ecr | 8 ++--- src/invidious/views/search.ecr | 16 ++++----- src/invidious/views/subscription_manager.ecr | 10 +++--- src/invidious/views/subscriptions.ecr | 24 ++++++------- src/invidious/views/trending.ecr | 8 ++--- src/invidious/views/view_all_playlists.ecr | 16 ++++----- src/invidious/views/watch.ecr | 21 ++++++----- 19 files changed, 161 insertions(+), 169 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 81d6ac2b..8877f52b 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -312,6 +312,8 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) author_thumbnail = "" end + author_name = HTML.escape(child["author"].as_s) + html << <<-END_HTML
@@ -320,7 +322,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)

- #{child["author"]} + #{author_name}

#{child["contentHtml"]}

END_HTML diff --git a/src/invidious/views/authorize_token.ecr b/src/invidious/views/authorize_token.ecr index 8ea99010..2dc948d9 100644 --- a/src/invidious/views/authorize_token.ecr +++ b/src/invidious/views/authorize_token.ecr @@ -9,13 +9,13 @@ <%= translate(locale, "Token") %>

-
-

+ -
-

+ diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index dd2807de..09cfb76e 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -1,6 +1,9 @@ +<% ucid = channel.ucid %> +<% author = HTML.escape(channel.author) %> + <% content_for "header" do %> -<%= channel.author %> - Invidious - +<%= author %> - Invidious + <% end %> <% if channel.banner %> @@ -17,12 +20,12 @@
- <%= channel.author %> + <%= author %>
-
+

- +

@@ -34,15 +37,13 @@

- <% ucid = channel.ucid %> - <% author = channel.author %> <% sub_count_text = number_to_short_text(channel.sub_count) %> <%= rendered "components/subscribe_widget" %>
@@ -72,7 +73,7 @@ <% if sort_by == sort %> <%= translate(locale, sort) %> <% else %> - + <%= translate(locale, sort) %> <% end %> @@ -87,17 +88,15 @@

- <% items.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% items.each do |item| %> + <%= rendered "components/item" %> +<% end %>
<% if page > 1 %> - &sort_by=<%= HTML.escape(sort_by) %><% end %>"> + &sort_by=<%= HTML.escape(sort_by) %><% end %>"> <%= translate(locale, "Previous page") %> <% end %> @@ -105,7 +104,7 @@
<% if count == 60 %> - &sort_by=<%= HTML.escape(sort_by) %><% end %>"> + &sort_by=<%= HTML.escape(sort_by) %><% end %>"> <%= translate(locale, "Next page") %> <% end %> diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 96976271..15d8ed1e 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -1,5 +1,8 @@ +<% ucid = channel.ucid %> +<% author = HTML.escape(channel.author) %> + <% content_for "header" do %> -<%= channel.author %> - Invidious +<%= author %> - Invidious <% end %> <% if channel.banner %> @@ -16,7 +19,7 @@
- <%= channel.author %> + <%= author %>
@@ -33,8 +36,6 @@
- <% ucid = channel.ucid %> - <% author = channel.author %> <% sub_count_text = number_to_short_text(channel.sub_count) %> <%= rendered "components/subscribe_widget" %>
@@ -79,7 +80,7 @@
- <% watched.each_slice(4) do |slice| %> - <% slice.each do |item| %> - - <% end %> + <% watched.each do |item| %> + <% end %>
diff --git a/src/invidious/views/login.ecr b/src/invidious/views/login.ecr index b6e8117b..1f6618e8 100644 --- a/src/invidious/views/login.ecr +++ b/src/invidious/views/login.ecr @@ -26,7 +26,7 @@
<% if email %> - + <% else %> "> @@ -62,7 +62,7 @@
<% if email %> - + <% else %> "> diff --git a/src/invidious/views/mix.ecr b/src/invidious/views/mix.ecr index e9c0dcbc..e55b00f8 100644 --- a/src/invidious/views/mix.ecr +++ b/src/invidious/views/mix.ecr @@ -1,22 +1,20 @@ <% content_for "header" do %> -<%= mix.title %> - Invidious +<%= HTML.escape(mix.title) %> - Invidious <% end %>
-

<%= mix.title %>

+

<%= HTML.escape(mix.title) %>

-
-

+
+

- <% mix.videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% mix.videos.each do |item| %> + <%= rendered "components/item" %> +<% end %>
diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 377da20f..b1fee211 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -1,17 +1,20 @@ +<% title = HTML.escape(playlist.title) %> +<% author = HTML.escape(playlist.author) %> + <% content_for "header" do %> -<%= playlist.title %> - Invidious +<%= title %> - Invidious <% end %>
-

<%= playlist.title %>

+

<%= title %>

<% if playlist.is_a? InvidiousPlaylist %> <% if playlist.author == user.try &.email %> - <%= playlist.author %> | + <%= author %> | <% else %> - <%= playlist.author %> | + <%= author %> | <% end %> <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | @@ -26,11 +29,12 @@ <% else %> - <%= playlist.author %> | + <%= author %> | <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> <% end %> + <% if !playlist.is_a? InvidiousPlaylist %> <% end %>
@@ -93,11 +96,9 @@ <% end %>
- <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% videos.each do |item| %> + <%= rendered "components/item" %> +<% end %>
diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 3f892650..d9a17a9b 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -1,5 +1,8 @@ +<% ucid = channel.ucid %> +<% author = HTML.escape(channel.author) %> + <% content_for "header" do %> -<%= channel.author %> - Invidious +<%= author %> - Invidious <% end %> <% if channel.banner %> @@ -16,12 +19,12 @@
- <%= channel.author %> + <%= author %>

- +

@@ -33,8 +36,6 @@
- <% ucid = channel.ucid %> - <% author = channel.author %> <% sub_count_text = number_to_short_text(channel.sub_count) %> <%= rendered "components/subscribe_widget" %>
@@ -42,7 +43,7 @@
@@ -71,7 +72,7 @@ <% if sort_by == sort %> <%= translate(locale, sort) %> <% else %> - + <%= translate(locale, sort) %> <% end %> @@ -86,18 +87,16 @@
- <% items.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% items.each do |item| %> + <%= rendered "components/item" %> +<% end %>
<% if continuation %> - &sort_by=<%= HTML.escape(sort_by) %><% end %>"> + &sort_by=<%= HTML.escape(sort_by) %><% end %>"> <%= translate(locale, "Next page") %> <% end %> diff --git a/src/invidious/views/popular.ecr b/src/invidious/views/popular.ecr index 62abb12a..e77f35b9 100644 --- a/src/invidious/views/popular.ecr +++ b/src/invidious/views/popular.ecr @@ -12,9 +12,7 @@ <%= rendered "components/feed_menu" %>
- <% popular_videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% popular_videos.each do |item| %> + <%= rendered "components/item" %> +<% end %>
diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index 15389dce..fd176e41 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -2,6 +2,8 @@ <%= search_query.not_nil!.size > 30 ? HTML.escape(query.not_nil![0,30].rstrip(".") + "...") : HTML.escape(query.not_nil!) %> - Invidious <% end %> +<% search_query_encoded = env.get?("search").try { |x| URI.encode(x.as(String), space_to_plus: true) } %> + <% if count == 0 %>

@@ -105,7 +107,7 @@
<% if page > 1 %> - + <%= translate(locale, "Previous page") %> <% end %> @@ -113,7 +115,7 @@
<% if count >= 20 %> - + <%= translate(locale, "Next page") %> <% end %> @@ -121,17 +123,15 @@
- <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> + <% videos.each do |item| %> + <%= rendered "components/item" %> <% end %>
<% if page > 1 %> - + <%= translate(locale, "Previous page") %> <% end %> @@ -139,7 +139,7 @@
<% if count >= 20 %> - + <%= translate(locale, "Next page") %> <% end %> diff --git a/src/invidious/views/subscription_manager.ecr b/src/invidious/views/subscription_manager.ecr index 6cddcd6c..acf015f5 100644 --- a/src/invidious/views/subscription_manager.ecr +++ b/src/invidious/views/subscription_manager.ecr @@ -10,15 +10,15 @@

-
-

+ -
-

+
+

<%= translate(locale, "Import/export") %> @@ -31,7 +31,7 @@
diff --git a/src/invidious/views/subscriptions.ecr b/src/invidious/views/subscriptions.ecr index af1d4fbc..97184e2b 100644 --- a/src/invidious/views/subscriptions.ecr +++ b/src/invidious/views/subscriptions.ecr @@ -11,13 +11,13 @@ <%= translate(locale, "Manage subscriptions") %>

-
-

+ -
-

+
+

@@ -34,11 +34,9 @@ <% end %>
- <% notifications.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% notifications.each do |item| %> + <%= rendered "components/item" %> +<% end %>
@@ -55,11 +53,9 @@
- <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% videos.each do |item| %> + <%= rendered "components/item" %> +<% end %>
diff --git a/src/invidious/views/trending.ecr b/src/invidious/views/trending.ecr index 3ec62555..a35c4ee3 100644 --- a/src/invidious/views/trending.ecr +++ b/src/invidious/views/trending.ecr @@ -41,9 +41,7 @@
- <% trending.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% trending.each do |item| %> + <%= rendered "components/item" %> +<% end %>
diff --git a/src/invidious/views/view_all_playlists.ecr b/src/invidious/views/view_all_playlists.ecr index 5ec6aa31..868cfeda 100644 --- a/src/invidious/views/view_all_playlists.ecr +++ b/src/invidious/views/view_all_playlists.ecr @@ -16,11 +16,9 @@
- <% items_created.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% items_created.each do |item| %> + <%= rendered "components/item" %> +<% end %>
@@ -30,9 +28,7 @@
- <% items_saved.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> +<% items_saved.each do |item| %> + <%= rendered "components/item" %> +<% end %>
diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index c553dc0e..aeb0f476 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -1,10 +1,15 @@ +<% ucid = video.ucid %> +<% title = HTML.escape(video.title) %> +<% author = HTML.escape(video.author) %> + + <% content_for "header" do %> "> - + @@ -16,7 +21,7 @@ - + @@ -24,7 +29,7 @@ <%= rendered "components/player_sources" %> -<%= HTML.escape(video.title) %> - Invidious +<%= title %> - Invidious #{end_time}.000 - #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} - - - END_CUE - - start_time += storyboard[:interval].milliseconds - end_time += storyboard[:interval].milliseconds - end - end - end - end -end - -get "/api/v1/captions/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 - # It is possible to use `/api/timedtext?type=list&v=#{id}` and - # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly, - # but this does not provide links for auto-generated captions. - # - # In future this should be investigated as an alternative, since it does not require - # getting video info. - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - env.response.status_code = 500 - next - end - - captions = video.captions - - label = env.params.query["label"]? - lang = env.params.query["lang"]? - tlang = env.params.query["tlang"]? - - if !label && !lang - response = JSON.build do |json| - json.object do - json.field "captions" do - json.array do - captions.each do |caption| - json.object do - json.field "label", caption.name - json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" - end - end - end - end - end - end - - next response - end - - env.response.content_type = "text/vtt; charset=UTF-8" - - if lang - caption = captions.select { |caption| caption.languageCode == lang } - else - caption = captions.select { |caption| caption.name == label } - end - - if caption.empty? - env.response.status_code = 404 - next - else - caption = caption[0] - end - - url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target - - # Auto-generated captions often have cues that aren't aligned properly with the video, - # as well as some other markup that makes it cumbersome, so we try to fix that here - if caption.name.includes? "auto-generated" - caption_xml = YT_POOL.client &.get(url).body - caption_xml = XML.parse(caption_xml) - - webvtt = String.build do |str| - str << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || caption.languageCode} - - - END_VTT - - caption_nodes = caption_xml.xpath_nodes("//transcript/text") - caption_nodes.each_with_index do |node, i| - start_time = node["start"].to_f.seconds - duration = node["dur"]?.try &.to_f.seconds - duration ||= start_time - - if caption_nodes.size > i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration - end - - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - - text = HTML.unescape(node.content) - text = text.gsub(//, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?.*) : (?.*)/) - text = "#{md["text"]}" - end - - str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE - end - end - else - webvtt = YT_POOL.client &.get("#{url}&format=vtt").body - end - - if title = env.params.query["title"]? - # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" - end - - webvtt -end - -get "/api/v1/comments/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - - source = env.params.query["source"]? - source ||= "youtube" - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort_by"]?.try &.downcase - - if source == "youtube" - sort_by ||= "top" - - begin - comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) - rescue ex - next error_json(500, ex) - end - - next comments - elsif source == "reddit" - sort_by ||= "confidence" - - begin - comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) - content_html = template_reddit_comments(comments, locale) - - content_html = fill_links(content_html, "https", "www.reddit.com") - content_html = replace_links(content_html) - rescue ex - comments = nil - reddit_thread = nil - content_html = "" - end - - if !reddit_thread || !comments - env.response.status_code = 404 - next - end - - if format == "json" - reddit_thread = JSON.parse(reddit_thread.to_json).as_h - reddit_thread["comments"] = JSON.parse(comments.to_json) - - next reddit_thread.to_json - else - response = { - "title" => reddit_thread.title, - "permalink" => reddit_thread.permalink, - "contentHtml" => content_html, - } - - next response.to_json - end - end -end - -get "/api/v1/annotations/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "text/xml" - - id = env.params.url["id"] - source = env.params.query["source"]? - source ||= "archive" - - if !id.match(/[a-zA-Z0-9_-]{11}/) - env.response.status_code = 400 - next - end - - annotations = "" - - case source - when "archive" - if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) - annotations = cached_annotation.annotations - else - index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') - - # IA doesn't handle leading hyphens, - # so we use https://archive.org/details/youtubeannotations_64 - if index == "62" - index = "64" - id = id.sub(/^-/, 'A') - end - - file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") - - location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) - - if !location.headers["Location"]? - env.response.status_code = location.status_code - end - - response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) - - if response.body.empty? - env.response.status_code = 404 - next - end - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - annotations = response.body - - cache_annotation(PG_DB, id, annotations) - end - else # "youtube" - response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - annotations = response.body - end - - etag = sha256(annotations)[0, 16] - if env.request.headers["If-None-Match"]?.try &.== etag - env.response.status_code = 304 - else - env.response.headers["ETag"] = etag - annotations - end -end - get "/api/v1/videos/:id" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? @@ -2016,324 +1659,6 @@ get "/api/v1/videos/:id" do |env| video.to_json(locale) end -get "/api/v1/trending" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - region = env.params.query["region"]? - trending_type = env.params.query["type"]? - - begin - trending, plid = fetch_trending(trending_type, region, locale) - rescue ex - next error_json(500, ex) - end - - videos = JSON.build do |json| - json.array do - trending.each do |video| - video.to_json(locale, json) - end - end - end - - videos -end - -get "/api/v1/popular" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - if !CONFIG.popular_enabled - error_message = {"error" => "Administrator has disabled this endpoint."}.to_json - env.response.status_code = 400 - next error_message - end - - JSON.build do |json| - json.array do - popular_videos.each do |video| - video.to_json(locale, json) - end - end - end -end - -get "/api/v1/channels/:ucid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - next error_json(500, ex) - end - - page = 1 - if channel.auto_generated - videos = [] of SearchVideo - count = 0 - else - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - next error_json(500, ex) - end - end - - JSON.build do |json| - # TODO: Refactor into `to_json` for InvidiousChannel - json.object do - json.field "author", channel.author - json.field "authorId", channel.ucid - json.field "authorUrl", channel.author_url - - json.field "authorBanners" do - json.array do - if channel.banner - qualities = { - {width: 2560, height: 424}, - {width: 2120, height: 351}, - {width: 1060, height: 175}, - } - qualities.each do |quality| - json.object do - json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-") - json.field "width", quality[:width] - json.field "height", quality[:height] - end - end - - json.object do - json.field "url", channel.banner.not_nil!.split("=w1060-")[0] - json.field "width", 512 - json.field "height", 288 - end - end - end - end - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "subCount", channel.sub_count - json.field "totalViews", channel.total_views - json.field "joined", channel.joined.to_unix - - json.field "autoGenerated", channel.auto_generated - json.field "isFamilyFriendly", channel.is_family_friendly - json.field "description", html_to_content(channel.description_html) - json.field "descriptionHtml", channel.description_html - - json.field "allowedRegions", channel.allowed_regions - - json.field "latestVideos" do - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - - json.field "relatedChannels" do - json.array do - channel.related_channels.each do |related_channel| - json.object do - json.field "author", related_channel.author - json.field "authorId", related_channel.ucid - json.field "authorUrl", related_channel.author_url - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - end - end - end - end - end - end -end - -{"/api/v1/channels/:ucid/videos", "/api/v1/channels/videos/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - sort_by = env.params.query["sort"]?.try &.downcase - sort_by ||= env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - next error_json(500, ex) - end - - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - next error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end -end - -{"/api/v1/channels/:ucid/latest", "/api/v1/channels/latest/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - begin - videos = get_latest_videos(ucid) - rescue ex - next error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end -end - -{"/api/v1/channels/:ucid/playlists", "/api/v1/channels/playlists/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort"]?.try &.downcase || - env.params.query["sort_by"]?.try &.downcase || - "last" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - next error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - next error_json(500, ex) - end - - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) - - JSON.build do |json| - json.object do - json.field "playlists" do - json.array do - items.each do |item| - item.to_json(locale, json) if item.is_a?(SearchPlaylist) - end - end - end - - json.field "continuation", continuation - end - end - end -end - -{"/api/v1/channels/:ucid/comments", "/api/v1/channels/comments/:ucid"}.each do |route| - get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - continuation = env.params.query["continuation"]? - # sort_by = env.params.query["sort_by"]?.try &.downcase - - begin - fetch_channel_community(ucid, continuation, locale, format, thin_mode) - rescue ex - next error_json(500, ex) - end - end -end - -get "/api/v1/channels/search/:ucid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - query = env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - count, search_results = channel_search(query, page, ucid) - JSON.build do |json| - json.array do - search_results.each do |item| - item.to_json(locale, json) - end - end - end -end - get "/api/v1/search" do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? region = env.params.query["region"]? @@ -2377,40 +1702,6 @@ get "/api/v1/search" do |env| end end -get "/api/v1/search/suggestions" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - query = env.params.query["q"]? - query ||= "" - - begin - headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} - response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body - - body = response[35..-2] - body = JSON.parse(body).as_a - suggestions = body[1].as_a[0..-2] - - JSON.build do |json| - json.object do - json.field "query", body[0].as_s - json.field "suggestions" do - json.array do - suggestions.each do |suggestion| - json.string suggestion[0].as_s - end - end - end - end - end - rescue ex - next error_json(500, ex) - end -end - {"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route| get route do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? diff --git a/src/invidious/routes/API/v1/channels.cr b/src/invidious/routes/API/v1/channels.cr new file mode 100644 index 00000000..149b1067 --- /dev/null +++ b/src/invidious/routes/API/v1/channels.cr @@ -0,0 +1,267 @@ +class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute + def home(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + sort_by = env.params.query["sort_by"]?.try &.downcase + sort_by ||= "newest" + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) + end + + page = 1 + if channel.auto_generated + videos = [] of SearchVideo + count = 0 + else + begin + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + rescue ex + return error_json(500, ex) + end + end + + JSON.build do |json| + # TODO: Refactor into `to_json` for InvidiousChannel + json.object do + json.field "author", channel.author + json.field "authorId", channel.ucid + json.field "authorUrl", channel.author_url + + json.field "authorBanners" do + json.array do + if channel.banner + qualities = { + {width: 2560, height: 424}, + {width: 2120, height: 351}, + {width: 1060, height: 175}, + } + qualities.each do |quality| + json.object do + json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-") + json.field "width", quality[:width] + json.field "height", quality[:height] + end + end + + json.object do + json.field "url", channel.banner.not_nil!.split("=w1060-")[0] + json.field "width", 512 + json.field "height", 288 + end + end + end + end + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "subCount", channel.sub_count + json.field "totalViews", channel.total_views + json.field "joined", channel.joined.to_unix + json.field "paid", channel.paid + + json.field "autoGenerated", channel.auto_generated + json.field "isFamilyFriendly", channel.is_family_friendly + json.field "description", html_to_content(channel.description_html) + json.field "descriptionHtml", channel.description_html + + json.field "allowedRegions", channel.allowed_regions + + json.field "latestVideos" do + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + + json.field "relatedChannels" do + json.array do + channel.related_channels.each do |related_channel| + json.object do + json.field "author", related_channel.author + json.field "authorId", related_channel.ucid + json.field "authorUrl", related_channel.author_url + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + end + end + end + end + end + end + end + + def latest(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + begin + videos = get_latest_videos(ucid) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + + def videos(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + sort_by = env.params.query["sort"]?.try &.downcase + sort_by ||= env.params.query["sort_by"]?.try &.downcase + sort_by ||= "newest" + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) + end + + begin + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + + def playlists(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort"]?.try &.downcase || + env.params.query["sort_by"]?.try &.downcase || + "last" + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) + end + + items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", continuation + end + end + end + + def community(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + continuation = env.params.query["continuation"]? + # sort_by = env.params.query["sort_by"]?.try &.downcase + + begin + fetch_channel_community(ucid, continuation, locale, format, thin_mode) + rescue ex + return error_json(500, ex) + end + end + + def channel_search(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + query = env.params.query["q"]? + query ||= "" + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + count, search_results = channel_search(query, page, ucid) + JSON.build do |json| + json.array do + search_results.each do |item| + item.to_json(locale, json) + end + end + end + end +end diff --git a/src/invidious/routes/API/v1/feeds.cr b/src/invidious/routes/API/v1/feeds.cr new file mode 100644 index 00000000..513c76db --- /dev/null +++ b/src/invidious/routes/API/v1/feeds.cr @@ -0,0 +1,116 @@ +class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute + def comments(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + + source = env.params.query["source"]? + source ||= "youtube" + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + action = env.params.query["action"]? + action ||= "action_get_comments" + + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort_by"]?.try &.downcase + + if source == "youtube" + sort_by ||= "top" + + begin + comments = fetch_youtube_comments(id, PG_DB, continuation, format, locale, thin_mode, region, sort_by: sort_by, action: action) + rescue ex + return error_json(500, ex) + end + + return comments + elsif source == "reddit" + sort_by ||= "confidence" + + begin + comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) + content_html = template_reddit_comments(comments, locale) + + content_html = fill_links(content_html, "https", "www.reddit.com") + content_html = replace_links(content_html) + rescue ex + comments = nil + reddit_thread = nil + content_html = "" + end + + if !reddit_thread || !comments + env.response.status_code = 404 + return + end + + if format == "json" + reddit_thread = JSON.parse(reddit_thread.to_json).as_h + reddit_thread["comments"] = JSON.parse(comments.to_json) + + return reddit_thread.to_json + else + response = { + "title" => reddit_thread.title, + "permalink" => reddit_thread.permalink, + "contentHtml" => content_html, + } + + return response.to_json + end + end + end + + def trending(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + region = env.params.query["region"]? + trending_type = env.params.query["type"]? + + begin + trending, plid = fetch_trending(trending_type, region, locale) + rescue ex + return error_json(500, ex) + end + + videos = JSON.build do |json| + json.array do + trending.each do |video| + video.to_json(locale, json) + end + end + end + + videos + end + + def popular(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + if !CONFIG.popular_enabled + error_message = {"error" => "Administrator has disabled this endpoint."}.to_json + env.response.status_code = 400 + return error_message + end + + JSON.build do |json| + json.array do + popular_videos.each do |video| + video.to_json(locale, json) + end + end + end + end +end diff --git a/src/invidious/routes/API/v1/misc.cr b/src/invidious/routes/API/v1/misc.cr new file mode 100644 index 00000000..02aa50c2 --- /dev/null +++ b/src/invidious/routes/API/v1/misc.cr @@ -0,0 +1,13 @@ +class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute + # Stats API endpoint for Invidious + def stats(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + env.response.content_type = "application/json" + + if !CONFIG.statistics_enabled + return error_json(400, "Statistics are not enabled.") + end + + Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json + end +end diff --git a/src/invidious/routes/API/v1/routes.cr b/src/invidious/routes/API/v1/routes.cr new file mode 100644 index 00000000..76dd138e --- /dev/null +++ b/src/invidious/routes/API/v1/routes.cr @@ -0,0 +1,30 @@ +# There is far too many API routes to define in invidious.cr +# so we'll just do it here instead with a macro. +macro define_v1_api_routes(base_url = "/api/v1") + Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::V1Api, :stats + + Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::V1Api, :storyboards + Invidious::Routing.get "#{{{base_url}}}/captions/:id", Invidious::Routes::V1Api, :captions + Invidious::Routing.get "#{{{base_url}}}/annotations/:id", Invidious::Routes::V1Api, :annotations + Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::V1Api, :search_suggestions + + Invidious::Routing.get "#{{{base_url}}}/comments/:id", Invidious::Routes::V1Api, :comments + Invidious::Routing.get "#{{{base_url}}}/trending", Invidious::Routes::V1Api, :trending + Invidious::Routing.get "#{{{base_url}}}/popular", Invidious::Routes::V1Api, :popular + + Invidious::Routing.get "#{{{base_url}}}/channels/:ucid", Invidious::Routes::V1Api, :home + + {% for route in { + {"home", "home"}, + {"videos", "videos"}, + {"latest", "latest"}, + {"playlists", "playlists"}, + {"comments", "community"}, # Why is the route for the community API `comments`?, + {"search", "channel_search"}, + } %} + + Invidious::Routing.get "#{{{base_url}}}/channels/#{{{route[0]}}}/:ucid", Invidious::Routes::V1Api, :{{route[1]}} + Invidious::Routing.get "#{{{base_url}}}/channels/:ucid/#{{{route[0]}}}", Invidious::Routes::V1Api, :{{route[1]}} + + {% end %} +end diff --git a/src/invidious/routes/API/v1/widgets.cr b/src/invidious/routes/API/v1/widgets.cr new file mode 100644 index 00000000..d1a1213b --- /dev/null +++ b/src/invidious/routes/API/v1/widgets.cr @@ -0,0 +1,316 @@ +class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute + # Fetches YouTube storyboards + # + # Which are sprites containing x * y preview + # thumbnails for individual scenes in a video. + # See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails + def storyboards(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + begin + video = get_video(id, PG_DB, region: region) + rescue ex : VideoRedirect + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) + rescue ex + env.response.status_code = 500 + return + end + + storyboards = video.storyboards + width = env.params.query["width"]? + height = env.params.query["height"]? + + if !width && !height + response = JSON.build do |json| + json.object do + json.field "storyboards" do + generate_storyboards(json, id, storyboards) + end + end + end + + return response + end + + env.response.content_type = "text/vtt" + + storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" } + + if storyboard.empty? + env.response.status_code = 404 + return + else + storyboard = storyboard[0] + end + + String.build do |str| + str << <<-END_VTT + WEBVTT + END_VTT + + start_time = 0.milliseconds + end_time = storyboard[:interval].milliseconds + + storyboard[:storyboard_count].times do |i| + url = storyboard[:url] + authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? + url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") + url = "#{HOST_URL}/sb/#{authority}/#{url}" + + storyboard[:storyboard_height].times do |j| + storyboard[:storyboard_width].times do |k| + str << <<-END_CUE + #{start_time}.000 --> #{end_time}.000 + #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} + + + END_CUE + + start_time += storyboard[:interval].milliseconds + end_time += storyboard[:interval].milliseconds + end + end + end + end + end + + def captions(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 + # It is possible to use `/api/timedtext?type=list&v=#{id}` and + # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly, + # but this does not provide links for auto-generated captions. + # + # In future this should be investigated as an alternative, since it does not require + # getting video info. + + begin + video = get_video(id, PG_DB, region: region) + rescue ex : VideoRedirect + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) + rescue ex + env.response.status_code = 500 + return + end + + captions = video.captions + + label = env.params.query["label"]? + lang = env.params.query["lang"]? + tlang = env.params.query["tlang"]? + + if !label && !lang + response = JSON.build do |json| + json.object do + json.field "captions" do + json.array do + captions.each do |caption| + json.object do + json.field "label", caption.name + json.field "languageCode", caption.languageCode + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" + end + end + end + end + end + end + + return response + end + + env.response.content_type = "text/vtt; charset=UTF-8" + + if lang + caption = captions.select { |caption| caption.languageCode == lang } + else + caption = captions.select { |caption| caption.name == label } + end + + if caption.empty? + env.response.status_code = 404 + return + else + caption = caption[0] + end + + url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target + + # Auto-generated captions often have cues that aren't aligned properly with the video, + # as well as some other markup that makes it cumbersome, so we try to fix that here + if caption.name.includes? "auto-generated" + caption_xml = YT_POOL.client &.get(url).body + caption_xml = XML.parse(caption_xml) + + webvtt = String.build do |str| + str << <<-END_VTT + WEBVTT + Kind: captions + Language: #{tlang || caption.languageCode} + + + END_VTT + + caption_nodes = caption_xml.xpath_nodes("//transcript/text") + caption_nodes.each_with_index do |node, i| + start_time = node["start"].to_f.seconds + duration = node["dur"]?.try &.to_f.seconds + duration ||= start_time + + if caption_nodes.size > i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration + end + + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + + text = HTML.unescape(node.content) + text = text.gsub(//, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?.*) : (?.*)/) + text = "#{md["text"]}" + end + + str << <<-END_CUE + #{start_time} --> #{end_time} + #{text} + + + END_CUE + end + end + else + webvtt = YT_POOL.client &.get("#{url}&format=vtt").body + end + + if title = env.params.query["title"]? + # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ + env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" + end + + webvtt + end + + def annotations(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "text/xml" + + id = env.params.url["id"] + source = env.params.query["source"]? + source ||= "archive" + + if !id.match(/[a-zA-Z0-9_-]{11}/) + env.response.status_code = 400 + return + end + + annotations = "" + + case source + when "archive" + if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) + annotations = cached_annotation.annotations + else + index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') + + # IA doesn't handle leading hyphens, + # so we use https://archive.org/details/youtubeannotations_64 + if index == "62" + index = "64" + id = id.sub(/^-/, 'A') + end + + file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") + + location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) + + if !location.headers["Location"]? + env.response.status_code = location.status_code + end + + response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) + + if response.body.empty? + env.response.status_code = 404 + return + end + + if response.status_code != 200 + env.response.status_code = response.status_code + return + end + + annotations = response.body + + cache_annotation(PG_DB, id, annotations) + end + else # "youtube" + response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") + + if response.status_code != 200 + env.response.status_code = response.status_code + return + end + + annotations = response.body + end + + etag = sha256(annotations)[0, 16] + if env.request.headers["If-None-Match"]?.try &.== etag + env.response.status_code = 304 + else + env.response.headers["ETag"] = etag + annotations + end + end + + def search_suggestions(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + query = env.params.query["q"]? + query ||= "" + + begin + headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} + response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body + + body = response[35..-2] + body = JSON.parse(body).as_a + suggestions = body[1].as_a[0..-2] + + JSON.build do |json| + json.object do + json.field "query", body[0].as_s + json.field "suggestions" do + json.array do + suggestions.each do |suggestion| + json.string suggestion[0].as_s + end + end + end + end + end + rescue ex + return error_json(500, ex) + end + end +end -- cgit v1.2.3 From 66becbf46f7414a16bc4bde7430eeb3d5a8d0f8c Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 12 Aug 2021 11:46:03 -0700 Subject: Restructure API route organisation --- src/invidious/routes/API/v1/channels.cr | 267 ------------------ src/invidious/routes/API/v1/feeds.cr | 116 -------- src/invidious/routes/API/v1/misc.cr | 13 - src/invidious/routes/API/v1/routes.cr | 30 -- src/invidious/routes/API/v1/widgets.cr | 316 --------------------- src/invidious/routes/api_v1/channels.cr | 244 ++++++++++++++++ src/invidious/routes/api_v1/feeds.cr | 46 +++ src/invidious/routes/api_v1/misc.cr | 13 + src/invidious/routes/api_v1/routes.cr | 30 ++ src/invidious/routes/api_v1/search.cr | 24 ++ src/invidious/routes/api_v1/video_playback.cr | 2 + src/invidious/routes/api_v1/widgets.cr | 386 ++++++++++++++++++++++++++ 12 files changed, 745 insertions(+), 742 deletions(-) delete mode 100644 src/invidious/routes/API/v1/channels.cr delete mode 100644 src/invidious/routes/API/v1/feeds.cr delete mode 100644 src/invidious/routes/API/v1/misc.cr delete mode 100644 src/invidious/routes/API/v1/routes.cr delete mode 100644 src/invidious/routes/API/v1/widgets.cr create mode 100644 src/invidious/routes/api_v1/channels.cr create mode 100644 src/invidious/routes/api_v1/feeds.cr create mode 100644 src/invidious/routes/api_v1/misc.cr create mode 100644 src/invidious/routes/api_v1/routes.cr create mode 100644 src/invidious/routes/api_v1/search.cr create mode 100644 src/invidious/routes/api_v1/video_playback.cr create mode 100644 src/invidious/routes/api_v1/widgets.cr (limited to 'src') diff --git a/src/invidious/routes/API/v1/channels.cr b/src/invidious/routes/API/v1/channels.cr deleted file mode 100644 index 149b1067..00000000 --- a/src/invidious/routes/API/v1/channels.cr +++ /dev/null @@ -1,267 +0,0 @@ -class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute - def home(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - return error_json(500, ex) - end - - page = 1 - if channel.auto_generated - videos = [] of SearchVideo - count = 0 - else - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - return error_json(500, ex) - end - end - - JSON.build do |json| - # TODO: Refactor into `to_json` for InvidiousChannel - json.object do - json.field "author", channel.author - json.field "authorId", channel.ucid - json.field "authorUrl", channel.author_url - - json.field "authorBanners" do - json.array do - if channel.banner - qualities = { - {width: 2560, height: 424}, - {width: 2120, height: 351}, - {width: 1060, height: 175}, - } - qualities.each do |quality| - json.object do - json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-") - json.field "width", quality[:width] - json.field "height", quality[:height] - end - end - - json.object do - json.field "url", channel.banner.not_nil!.split("=w1060-")[0] - json.field "width", 512 - json.field "height", 288 - end - end - end - end - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "subCount", channel.sub_count - json.field "totalViews", channel.total_views - json.field "joined", channel.joined.to_unix - json.field "paid", channel.paid - - json.field "autoGenerated", channel.auto_generated - json.field "isFamilyFriendly", channel.is_family_friendly - json.field "description", html_to_content(channel.description_html) - json.field "descriptionHtml", channel.description_html - - json.field "allowedRegions", channel.allowed_regions - - json.field "latestVideos" do - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - - json.field "relatedChannels" do - json.array do - channel.related_channels.each do |related_channel| - json.object do - json.field "author", related_channel.author - json.field "authorId", related_channel.ucid - json.field "authorUrl", related_channel.author_url - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - end - end - end - end - end - end - end - - def latest(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - begin - videos = get_latest_videos(ucid) - rescue ex - return error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end - - def videos(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - sort_by = env.params.query["sort"]?.try &.downcase - sort_by ||= env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - return error_json(500, ex) - end - - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - return error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end - - def playlists(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort"]?.try &.downcase || - env.params.query["sort_by"]?.try &.downcase || - "last" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - return error_json(500, ex) - end - - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) - - JSON.build do |json| - json.object do - json.field "playlists" do - json.array do - items.each do |item| - item.to_json(locale, json) if item.is_a?(SearchPlaylist) - end - end - end - - json.field "continuation", continuation - end - end - end - - def community(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - continuation = env.params.query["continuation"]? - # sort_by = env.params.query["sort_by"]?.try &.downcase - - begin - fetch_channel_community(ucid, continuation, locale, format, thin_mode) - rescue ex - return error_json(500, ex) - end - end - - def channel_search(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - query = env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - count, search_results = channel_search(query, page, ucid) - JSON.build do |json| - json.array do - search_results.each do |item| - item.to_json(locale, json) - end - end - end - end -end diff --git a/src/invidious/routes/API/v1/feeds.cr b/src/invidious/routes/API/v1/feeds.cr deleted file mode 100644 index 513c76db..00000000 --- a/src/invidious/routes/API/v1/feeds.cr +++ /dev/null @@ -1,116 +0,0 @@ -class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute - def comments(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - - source = env.params.query["source"]? - source ||= "youtube" - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - action = env.params.query["action"]? - action ||= "action_get_comments" - - continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort_by"]?.try &.downcase - - if source == "youtube" - sort_by ||= "top" - - begin - comments = fetch_youtube_comments(id, PG_DB, continuation, format, locale, thin_mode, region, sort_by: sort_by, action: action) - rescue ex - return error_json(500, ex) - end - - return comments - elsif source == "reddit" - sort_by ||= "confidence" - - begin - comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) - content_html = template_reddit_comments(comments, locale) - - content_html = fill_links(content_html, "https", "www.reddit.com") - content_html = replace_links(content_html) - rescue ex - comments = nil - reddit_thread = nil - content_html = "" - end - - if !reddit_thread || !comments - env.response.status_code = 404 - return - end - - if format == "json" - reddit_thread = JSON.parse(reddit_thread.to_json).as_h - reddit_thread["comments"] = JSON.parse(comments.to_json) - - return reddit_thread.to_json - else - response = { - "title" => reddit_thread.title, - "permalink" => reddit_thread.permalink, - "contentHtml" => content_html, - } - - return response.to_json - end - end - end - - def trending(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - region = env.params.query["region"]? - trending_type = env.params.query["type"]? - - begin - trending, plid = fetch_trending(trending_type, region, locale) - rescue ex - return error_json(500, ex) - end - - videos = JSON.build do |json| - json.array do - trending.each do |video| - video.to_json(locale, json) - end - end - end - - videos - end - - def popular(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - if !CONFIG.popular_enabled - error_message = {"error" => "Administrator has disabled this endpoint."}.to_json - env.response.status_code = 400 - return error_message - end - - JSON.build do |json| - json.array do - popular_videos.each do |video| - video.to_json(locale, json) - end - end - end - end -end diff --git a/src/invidious/routes/API/v1/misc.cr b/src/invidious/routes/API/v1/misc.cr deleted file mode 100644 index 02aa50c2..00000000 --- a/src/invidious/routes/API/v1/misc.cr +++ /dev/null @@ -1,13 +0,0 @@ -class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute - # Stats API endpoint for Invidious - def stats(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.response.content_type = "application/json" - - if !CONFIG.statistics_enabled - return error_json(400, "Statistics are not enabled.") - end - - Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json - end -end diff --git a/src/invidious/routes/API/v1/routes.cr b/src/invidious/routes/API/v1/routes.cr deleted file mode 100644 index 76dd138e..00000000 --- a/src/invidious/routes/API/v1/routes.cr +++ /dev/null @@ -1,30 +0,0 @@ -# There is far too many API routes to define in invidious.cr -# so we'll just do it here instead with a macro. -macro define_v1_api_routes(base_url = "/api/v1") - Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::V1Api, :stats - - Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::V1Api, :storyboards - Invidious::Routing.get "#{{{base_url}}}/captions/:id", Invidious::Routes::V1Api, :captions - Invidious::Routing.get "#{{{base_url}}}/annotations/:id", Invidious::Routes::V1Api, :annotations - Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::V1Api, :search_suggestions - - Invidious::Routing.get "#{{{base_url}}}/comments/:id", Invidious::Routes::V1Api, :comments - Invidious::Routing.get "#{{{base_url}}}/trending", Invidious::Routes::V1Api, :trending - Invidious::Routing.get "#{{{base_url}}}/popular", Invidious::Routes::V1Api, :popular - - Invidious::Routing.get "#{{{base_url}}}/channels/:ucid", Invidious::Routes::V1Api, :home - - {% for route in { - {"home", "home"}, - {"videos", "videos"}, - {"latest", "latest"}, - {"playlists", "playlists"}, - {"comments", "community"}, # Why is the route for the community API `comments`?, - {"search", "channel_search"}, - } %} - - Invidious::Routing.get "#{{{base_url}}}/channels/#{{{route[0]}}}/:ucid", Invidious::Routes::V1Api, :{{route[1]}} - Invidious::Routing.get "#{{{base_url}}}/channels/:ucid/#{{{route[0]}}}", Invidious::Routes::V1Api, :{{route[1]}} - - {% end %} -end diff --git a/src/invidious/routes/API/v1/widgets.cr b/src/invidious/routes/API/v1/widgets.cr deleted file mode 100644 index d1a1213b..00000000 --- a/src/invidious/routes/API/v1/widgets.cr +++ /dev/null @@ -1,316 +0,0 @@ -class Invidious::Routes::V1Api < Invidious::Routes::BaseRoute - # Fetches YouTube storyboards - # - # Which are sprites containing x * y preview - # thumbnails for individual scenes in a video. - # See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails - def storyboards(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - env.response.status_code = 500 - return - end - - storyboards = video.storyboards - width = env.params.query["width"]? - height = env.params.query["height"]? - - if !width && !height - response = JSON.build do |json| - json.object do - json.field "storyboards" do - generate_storyboards(json, id, storyboards) - end - end - end - - return response - end - - env.response.content_type = "text/vtt" - - storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" } - - if storyboard.empty? - env.response.status_code = 404 - return - else - storyboard = storyboard[0] - end - - String.build do |str| - str << <<-END_VTT - WEBVTT - END_VTT - - start_time = 0.milliseconds - end_time = storyboard[:interval].milliseconds - - storyboard[:storyboard_count].times do |i| - url = storyboard[:url] - authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? - url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") - url = "#{HOST_URL}/sb/#{authority}/#{url}" - - storyboard[:storyboard_height].times do |j| - storyboard[:storyboard_width].times do |k| - str << <<-END_CUE - #{start_time}.000 --> #{end_time}.000 - #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} - - - END_CUE - - start_time += storyboard[:interval].milliseconds - end_time += storyboard[:interval].milliseconds - end - end - end - end - end - - def captions(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 - # It is possible to use `/api/timedtext?type=list&v=#{id}` and - # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly, - # but this does not provide links for auto-generated captions. - # - # In future this should be investigated as an alternative, since it does not require - # getting video info. - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - env.response.status_code = 500 - return - end - - captions = video.captions - - label = env.params.query["label"]? - lang = env.params.query["lang"]? - tlang = env.params.query["tlang"]? - - if !label && !lang - response = JSON.build do |json| - json.object do - json.field "captions" do - json.array do - captions.each do |caption| - json.object do - json.field "label", caption.name - json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" - end - end - end - end - end - end - - return response - end - - env.response.content_type = "text/vtt; charset=UTF-8" - - if lang - caption = captions.select { |caption| caption.languageCode == lang } - else - caption = captions.select { |caption| caption.name == label } - end - - if caption.empty? - env.response.status_code = 404 - return - else - caption = caption[0] - end - - url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target - - # Auto-generated captions often have cues that aren't aligned properly with the video, - # as well as some other markup that makes it cumbersome, so we try to fix that here - if caption.name.includes? "auto-generated" - caption_xml = YT_POOL.client &.get(url).body - caption_xml = XML.parse(caption_xml) - - webvtt = String.build do |str| - str << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || caption.languageCode} - - - END_VTT - - caption_nodes = caption_xml.xpath_nodes("//transcript/text") - caption_nodes.each_with_index do |node, i| - start_time = node["start"].to_f.seconds - duration = node["dur"]?.try &.to_f.seconds - duration ||= start_time - - if caption_nodes.size > i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration - end - - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - - text = HTML.unescape(node.content) - text = text.gsub(//, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?.*) : (?.*)/) - text = "#{md["text"]}" - end - - str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE - end - end - else - webvtt = YT_POOL.client &.get("#{url}&format=vtt").body - end - - if title = env.params.query["title"]? - # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" - end - - webvtt - end - - def annotations(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "text/xml" - - id = env.params.url["id"] - source = env.params.query["source"]? - source ||= "archive" - - if !id.match(/[a-zA-Z0-9_-]{11}/) - env.response.status_code = 400 - return - end - - annotations = "" - - case source - when "archive" - if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) - annotations = cached_annotation.annotations - else - index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') - - # IA doesn't handle leading hyphens, - # so we use https://archive.org/details/youtubeannotations_64 - if index == "62" - index = "64" - id = id.sub(/^-/, 'A') - end - - file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") - - location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) - - if !location.headers["Location"]? - env.response.status_code = location.status_code - end - - response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) - - if response.body.empty? - env.response.status_code = 404 - return - end - - if response.status_code != 200 - env.response.status_code = response.status_code - return - end - - annotations = response.body - - cache_annotation(PG_DB, id, annotations) - end - else # "youtube" - response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") - - if response.status_code != 200 - env.response.status_code = response.status_code - return - end - - annotations = response.body - end - - etag = sha256(annotations)[0, 16] - if env.request.headers["If-None-Match"]?.try &.== etag - env.response.status_code = 304 - else - env.response.headers["ETag"] = etag - annotations - end - end - - def search_suggestions(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - query = env.params.query["q"]? - query ||= "" - - begin - headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} - response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body - - body = response[35..-2] - body = JSON.parse(body).as_a - suggestions = body[1].as_a[0..-2] - - JSON.build do |json| - json.object do - json.field "query", body[0].as_s - json.field "suggestions" do - json.array do - suggestions.each do |suggestion| - json.string suggestion[0].as_s - end - end - end - end - end - rescue ex - return error_json(500, ex) - end - end -end diff --git a/src/invidious/routes/api_v1/channels.cr b/src/invidious/routes/api_v1/channels.cr new file mode 100644 index 00000000..03ebebfb --- /dev/null +++ b/src/invidious/routes/api_v1/channels.cr @@ -0,0 +1,244 @@ +module Invidious::Routes::APIv1 + def self.home(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + sort_by = env.params.query["sort_by"]?.try &.downcase + sort_by ||= "newest" + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) + end + + page = 1 + if channel.auto_generated + videos = [] of SearchVideo + count = 0 + else + begin + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + rescue ex + return error_json(500, ex) + end + end + + JSON.build do |json| + # TODO: Refactor into `to_json` for InvidiousChannel + json.object do + json.field "author", channel.author + json.field "authorId", channel.ucid + json.field "authorUrl", channel.author_url + + json.field "authorBanners" do + json.array do + if channel.banner + qualities = { + {width: 2560, height: 424}, + {width: 2120, height: 351}, + {width: 1060, height: 175}, + } + qualities.each do |quality| + json.object do + json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-") + json.field "width", quality[:width] + json.field "height", quality[:height] + end + end + + json.object do + json.field "url", channel.banner.not_nil!.split("=w1060-")[0] + json.field "width", 512 + json.field "height", 288 + end + end + end + end + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "subCount", channel.sub_count + json.field "totalViews", channel.total_views + json.field "joined", channel.joined.to_unix + json.field "paid", channel.paid + + json.field "autoGenerated", channel.auto_generated + json.field "isFamilyFriendly", channel.is_family_friendly + json.field "description", html_to_content(channel.description_html) + json.field "descriptionHtml", channel.description_html + + json.field "allowedRegions", channel.allowed_regions + + json.field "latestVideos" do + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + + json.field "relatedChannels" do + json.array do + channel.related_channels.each do |related_channel| + json.object do + json.field "author", related_channel.author + json.field "authorId", related_channel.ucid + json.field "authorUrl", related_channel.author_url + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + end + end + end + end + end + end + end + + def self.latest(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + begin + videos = get_latest_videos(ucid) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + + def self.videos(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + sort_by = env.params.query["sort"]?.try &.downcase + sort_by ||= env.params.query["sort_by"]?.try &.downcase + sort_by ||= "newest" + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) + end + + begin + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + + def self.playlists(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort"]?.try &.downcase || + env.params.query["sort_by"]?.try &.downcase || + "last" + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) + end + + items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", continuation + end + end + end + + def self.community(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + continuation = env.params.query["continuation"]? + # sort_by = env.params.query["sort_by"]?.try &.downcase + + begin + fetch_channel_community(ucid, continuation, locale, format, thin_mode) + rescue ex + return error_json(500, ex) + end + end +end diff --git a/src/invidious/routes/api_v1/feeds.cr b/src/invidious/routes/api_v1/feeds.cr new file mode 100644 index 00000000..c24266c6 --- /dev/null +++ b/src/invidious/routes/api_v1/feeds.cr @@ -0,0 +1,46 @@ +module Invidious::Routes::APIv1 + def self.trending(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + region = env.params.query["region"]? + trending_type = env.params.query["type"]? + + begin + trending, plid = fetch_trending(trending_type, region, locale) + rescue ex + return error_json(500, ex) + end + + videos = JSON.build do |json| + json.array do + trending.each do |video| + video.to_json(locale, json) + end + end + end + + videos + end + + def self.popular(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + if !CONFIG.popular_enabled + error_message = {"error" => "Administrator has disabled this endpoint."}.to_json + env.response.status_code = 400 + return error_message + end + + JSON.build do |json| + json.array do + popular_videos.each do |video| + video.to_json(locale, json) + end + end + end + end +end diff --git a/src/invidious/routes/api_v1/misc.cr b/src/invidious/routes/api_v1/misc.cr new file mode 100644 index 00000000..4bf8b8b0 --- /dev/null +++ b/src/invidious/routes/api_v1/misc.cr @@ -0,0 +1,13 @@ +module Invidious::Routes::APIv1 + # Stats API endpoint for Invidious + def self.stats(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + env.response.content_type = "application/json" + + if !CONFIG.statistics_enabled + return error_json(400, "Statistics are not enabled.") + end + + Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json + end +end diff --git a/src/invidious/routes/api_v1/routes.cr b/src/invidious/routes/api_v1/routes.cr new file mode 100644 index 00000000..ec3d9dff --- /dev/null +++ b/src/invidious/routes/api_v1/routes.cr @@ -0,0 +1,30 @@ +# There is far too many API routes to define in invidious.cr +# so we'll just do it here instead with a macro. +macro define_v1_api_routes(base_url = "/api/v1") + Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1, :stats + + Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::APIv1, :storyboards + Invidious::Routing.get "#{{{base_url}}}/captions/:id", Invidious::Routes::APIv1, :captions + Invidious::Routing.get "#{{{base_url}}}/annotations/:id", Invidious::Routes::APIv1, :annotations + Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::APIv1, :search_suggestions + + Invidious::Routing.get "#{{{base_url}}}/comments/:id", Invidious::Routes::APIv1, :comments + Invidious::Routing.get "#{{{base_url}}}/trending", Invidious::Routes::APIv1, :trending + Invidious::Routing.get "#{{{base_url}}}/popular", Invidious::Routes::APIv1, :popular + + Invidious::Routing.get "#{{{base_url}}}/channels/:ucid", Invidious::Routes::APIv1, :home + + {% for route in { + {"home", "home"}, + {"videos", "videos"}, + {"latest", "latest"}, + {"playlists", "playlists"}, + {"comments", "community"}, # Why is the route for the community API `comments`?, + {"search", "channel_search"}, + } %} + + Invidious::Routing.get "#{{{base_url}}}/channels/#{{{route[0]}}}/:ucid", Invidious::Routes::APIv1, :{{route[1]}} + Invidious::Routing.get "#{{{base_url}}}/channels/:ucid/#{{{route[0]}}}", Invidious::Routes::APIv1, :{{route[1]}} + + {% end %} +end diff --git a/src/invidious/routes/api_v1/search.cr b/src/invidious/routes/api_v1/search.cr new file mode 100644 index 00000000..61fdadd8 --- /dev/null +++ b/src/invidious/routes/api_v1/search.cr @@ -0,0 +1,24 @@ +module Invidious::Routes::APIv1 + def self.channel_search(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + query = env.params.query["q"]? + query ||= "" + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + count, search_results = channel_search(query, page, ucid) + JSON.build do |json| + json.array do + search_results.each do |item| + item.to_json(locale, json) + end + end + end + end +end diff --git a/src/invidious/routes/api_v1/video_playback.cr b/src/invidious/routes/api_v1/video_playback.cr new file mode 100644 index 00000000..16942b22 --- /dev/null +++ b/src/invidious/routes/api_v1/video_playback.cr @@ -0,0 +1,2 @@ +module Invidious::Routes::APIv1 +end diff --git a/src/invidious/routes/api_v1/widgets.cr b/src/invidious/routes/api_v1/widgets.cr new file mode 100644 index 00000000..0b1cf67e --- /dev/null +++ b/src/invidious/routes/api_v1/widgets.cr @@ -0,0 +1,386 @@ +module Invidious::Routes::APIv1 + # Fetches YouTube storyboards + # + # Which are sprites containing x * y preview + # thumbnails for individual scenes in a video. + # See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails + def self.storyboards(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + begin + video = get_video(id, PG_DB, region: region) + rescue ex : VideoRedirect + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) + rescue ex + env.response.status_code = 500 + return + end + + storyboards = video.storyboards + width = env.params.query["width"]? + height = env.params.query["height"]? + + if !width && !height + response = JSON.build do |json| + json.object do + json.field "storyboards" do + generate_storyboards(json, id, storyboards) + end + end + end + + return response + end + + env.response.content_type = "text/vtt" + + storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" } + + if storyboard.empty? + env.response.status_code = 404 + return + else + storyboard = storyboard[0] + end + + String.build do |str| + str << <<-END_VTT + WEBVTT + END_VTT + + start_time = 0.milliseconds + end_time = storyboard[:interval].milliseconds + + storyboard[:storyboard_count].times do |i| + url = storyboard[:url] + authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? + url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") + url = "#{HOST_URL}/sb/#{authority}/#{url}" + + storyboard[:storyboard_height].times do |j| + storyboard[:storyboard_width].times do |k| + str << <<-END_CUE + #{start_time}.000 --> #{end_time}.000 + #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} + + + END_CUE + + start_time += storyboard[:interval].milliseconds + end_time += storyboard[:interval].milliseconds + end + end + end + end + end + + def self.captions(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 + # It is possible to use `/api/timedtext?type=list&v=#{id}` and + # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly, + # but this does not provide links for auto-generated captions. + # + # In future this should be investigated as an alternative, since it does not require + # getting video info. + + begin + video = get_video(id, PG_DB, region: region) + rescue ex : VideoRedirect + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) + rescue ex + env.response.status_code = 500 + return + end + + captions = video.captions + + label = env.params.query["label"]? + lang = env.params.query["lang"]? + tlang = env.params.query["tlang"]? + + if !label && !lang + response = JSON.build do |json| + json.object do + json.field "captions" do + json.array do + captions.each do |caption| + json.object do + json.field "label", caption.name + json.field "languageCode", caption.languageCode + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" + end + end + end + end + end + end + + return response + end + + env.response.content_type = "text/vtt; charset=UTF-8" + + if lang + caption = captions.select { |caption| caption.languageCode == lang } + else + caption = captions.select { |caption| caption.name == label } + end + + if caption.empty? + env.response.status_code = 404 + return + else + caption = caption[0] + end + + url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target + + # Auto-generated captions often have cues that aren't aligned properly with the video, + # as well as some other markup that makes it cumbersome, so we try to fix that here + if caption.name.includes? "auto-generated" + caption_xml = YT_POOL.client &.get(url).body + caption_xml = XML.parse(caption_xml) + + webvtt = String.build do |str| + str << <<-END_VTT + WEBVTT + Kind: captions + Language: #{tlang || caption.languageCode} + + + END_VTT + + caption_nodes = caption_xml.xpath_nodes("//transcript/text") + caption_nodes.each_with_index do |node, i| + start_time = node["start"].to_f.seconds + duration = node["dur"]?.try &.to_f.seconds + duration ||= start_time + + if caption_nodes.size > i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration + end + + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + + text = HTML.unescape(node.content) + text = text.gsub(//, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?.*) : (?.*)/) + text = "#{md["text"]}" + end + + str << <<-END_CUE + #{start_time} --> #{end_time} + #{text} + + + END_CUE + end + end + else + webvtt = YT_POOL.client &.get("#{url}&format=vtt").body + end + + if title = env.params.query["title"]? + # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ + env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" + end + + webvtt + end + + def self.annotations(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "text/xml" + + id = env.params.url["id"] + source = env.params.query["source"]? + source ||= "archive" + + if !id.match(/[a-zA-Z0-9_-]{11}/) + env.response.status_code = 400 + return + end + + annotations = "" + + case source + when "archive" + if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) + annotations = cached_annotation.annotations + else + index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') + + # IA doesn't handle leading hyphens, + # so we use https://archive.org/details/youtubeannotations_64 + if index == "62" + index = "64" + id = id.sub(/^-/, 'A') + end + + file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") + + location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) + + if !location.headers["Location"]? + env.response.status_code = location.status_code + end + + response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) + + if response.body.empty? + env.response.status_code = 404 + return + end + + if response.status_code != 200 + env.response.status_code = response.status_code + return + end + + annotations = response.body + + cache_annotation(PG_DB, id, annotations) + end + else # "youtube" + response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") + + if response.status_code != 200 + env.response.status_code = response.status_code + return + end + + annotations = response.body + end + + etag = sha256(annotations)[0, 16] + if env.request.headers["If-None-Match"]?.try &.== etag + env.response.status_code = 304 + else + env.response.headers["ETag"] = etag + annotations + end + end + + def self.search_suggestions(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + query = env.params.query["q"]? + query ||= "" + + begin + headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} + response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body + + body = response[35..-2] + body = JSON.parse(body).as_a + suggestions = body[1].as_a[0..-2] + + JSON.build do |json| + json.object do + json.field "query", body[0].as_s + json.field "suggestions" do + json.array do + suggestions.each do |suggestion| + json.string suggestion[0].as_s + end + end + end + end + end + rescue ex + return error_json(500, ex) + end + end + + def self.comments(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + + source = env.params.query["source"]? + source ||= "youtube" + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + action = env.params.query["action"]? + action ||= "action_get_comments" + + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort_by"]?.try &.downcase + + if source == "youtube" + sort_by ||= "top" + + begin + comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) + rescue ex + return error_json(500, ex) + end + + return comments + elsif source == "reddit" + sort_by ||= "confidence" + + begin + comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) + content_html = template_reddit_comments(comments, locale) + + content_html = fill_links(content_html, "https", "www.reddit.com") + content_html = replace_links(content_html) + rescue ex + comments = nil + reddit_thread = nil + content_html = "" + end + + if !reddit_thread || !comments + env.response.status_code = 404 + return + end + + if format == "json" + reddit_thread = JSON.parse(reddit_thread.to_json).as_h + reddit_thread["comments"] = JSON.parse(comments.to_json) + + return reddit_thread.to_json + else + response = { + "title" => reddit_thread.title, + "permalink" => reddit_thread.permalink, + "contentHtml" => content_html, + } + + return response.to_json + end + end + end +end -- cgit v1.2.3 From 6aa65593ef0dbadc0ef2735cd1d1bca0788370f1 Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 12 Aug 2021 23:31:12 -0700 Subject: Extract API routes from invidious.cr (2/?) - Video playback endpoints - Search feed api - Video info api --- src/invidious.cr | 575 +------------------------- src/invidious/channels/channels.cr | 1 + src/invidious/helpers/macros.cr | 9 + src/invidious/routes/api/manifest.cr | 237 +++++++++++ src/invidious/routes/api/v1/channels.cr | 243 +++++++++++ src/invidious/routes/api/v1/feeds.cr | 46 +++ src/invidious/routes/api/v1/misc.cr | 13 + src/invidious/routes/api/v1/routes.cr | 37 ++ src/invidious/routes/api/v1/search.cr | 101 +++++ src/invidious/routes/api/v1/videos.cr | 372 +++++++++++++++++ src/invidious/routes/api_v1/channels.cr | 244 ----------- src/invidious/routes/api_v1/feeds.cr | 46 --- src/invidious/routes/api_v1/misc.cr | 13 - src/invidious/routes/api_v1/routes.cr | 30 -- src/invidious/routes/api_v1/search.cr | 24 -- src/invidious/routes/api_v1/video_playback.cr | 2 - src/invidious/routes/api_v1/widgets.cr | 386 ----------------- src/invidious/routes/video_playback.cr | 290 +++++++++++++ 18 files changed, 1351 insertions(+), 1318 deletions(-) create mode 100644 src/invidious/routes/api/manifest.cr create mode 100644 src/invidious/routes/api/v1/channels.cr create mode 100644 src/invidious/routes/api/v1/feeds.cr create mode 100644 src/invidious/routes/api/v1/misc.cr create mode 100644 src/invidious/routes/api/v1/routes.cr create mode 100644 src/invidious/routes/api/v1/search.cr create mode 100644 src/invidious/routes/api/v1/videos.cr delete mode 100644 src/invidious/routes/api_v1/channels.cr delete mode 100644 src/invidious/routes/api_v1/feeds.cr delete mode 100644 src/invidious/routes/api_v1/misc.cr delete mode 100644 src/invidious/routes/api_v1/routes.cr delete mode 100644 src/invidious/routes/api_v1/search.cr delete mode 100644 src/invidious/routes/api_v1/video_playback.cr delete mode 100644 src/invidious/routes/api_v1/widgets.cr create mode 100644 src/invidious/routes/video_playback.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 6ac099f3..85852b9a 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -364,6 +364,8 @@ Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :up Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme define_v1_api_routes() +define_api_manifest_routes() +define_video_playback_routes() # Users @@ -1639,69 +1641,6 @@ end # API Endpoints -get "/api/v1/videos/:id" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - next error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - next error_json(500, ex) - end - - video.to_json(locale) -end - -get "/api/v1/search" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - query = env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "relevance" - - date = env.params.query["date"]?.try &.downcase - date ||= "" - - duration = env.params.query["duration"]?.try &.downcase - duration ||= "" - - features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase } - features ||= [] of String - - content_type = env.params.query["type"]?.try &.downcase - content_type ||= "video" - - begin - search_params = produce_search_params(page, sort_by, date, content_type, duration, features) - rescue ex - next error_json(400, ex) - end - - count, search_results = search(query, search_params, region).as(Tuple) - JSON.build do |json| - json.array do - search_results.each do |item| - item.to_json(locale, json) - end - end - end -end - {"/api/v1/playlists/:plid", "/api/v1/auth/playlists/:plid"}.each do |route| get route do |env| locale = LOCALES[env.get("preferences").as(Preferences).locale]? @@ -2245,516 +2184,6 @@ post "/api/v1/auth/tokens/unregister" do |env| env.response.status_code = 204 end -get "/api/manifest/dash/id/videoplayback" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.redirect "/videoplayback?#{env.params.query}" -end - -get "/api/manifest/dash/id/videoplayback/*" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.redirect env.request.path.lchop("/api/manifest/dash/id") -end - -get "/api/manifest/dash/id/:id" do |env| - env.response.headers.add("Access-Control-Allow-Origin", "*") - env.response.content_type = "application/dash+xml" - - local = env.params.query["local"]?.try &.== "true" - id = env.params.url["id"] - region = env.params.query["region"]? - - # Since some implementations create playlists based on resolution regardless of different codecs, - # we can opt to only add a source to a representation if it has a unique height within that representation - unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - next env.redirect env.request.resource.gsub(id, ex.video_id) - rescue ex - env.response.status_code = 403 - next - end - - if dashmpd = video.dash_manifest_url - manifest = YT_POOL.client &.get(URI.parse(dashmpd).request_target).body - - manifest = manifest.gsub(/[^<]+<\/BaseURL>/) do |baseurl| - url = baseurl.lchop("") - url = url.rchop("") - - if local - uri = URI.parse(url) - url = "#{uri.request_target}host/#{uri.host}/" - end - - "#{url}" - end - - next manifest - end - - adaptive_fmts = video.adaptive_fmts - - if local - adaptive_fmts.each do |fmt| - fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) - end - end - - audio_streams = video.audio_streams - video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse - - XML.build(indent: " ", encoding: "UTF-8") do |xml| - xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", - "profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static", - mediaPresentationDuration: "PT#{video.length_seconds}S") do - xml.element("Period") do - i = 0 - - {"audio/mp4", "audio/webm"}.each do |mime_type| - mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } - next if mime_streams.empty? - - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do - mime_streams.each do |fmt| - codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') - bandwidth = fmt["bitrate"].as_i - itag = fmt["itag"].as_i - url = fmt["url"].as_s - - xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do - xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", - value: "2") - xml.element("BaseURL") { xml.text url } - xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do - xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") - end - end - end - end - - i += 1 - end - - potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} - - {"video/mp4", "video/webm"}.each do |mime_type| - mime_streams = video_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } - next if mime_streams.empty? - - heights = [] of Int32 - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do - mime_streams.each do |fmt| - codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') - bandwidth = fmt["bitrate"].as_i - itag = fmt["itag"].as_i - url = fmt["url"].as_s - width = fmt["width"].as_i - height = fmt["height"].as_i - - # Resolutions reported by YouTube player (may not accurately reflect source) - height = potential_heights.min_by { |i| (height - i).abs } - next if unique_res && heights.includes? height - heights << height - - xml.element("Representation", id: itag, codecs: codecs, width: width, height: height, - startWithSAP: "1", maxPlayoutRate: "1", - bandwidth: bandwidth, frameRate: fmt["fps"]) do - xml.element("BaseURL") { xml.text url } - xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do - xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") - end - end - end - end - - i += 1 - end - end - end - end -end - -get "/api/manifest/hls_variant/*" do |env| - response = YT_POOL.client &.get(env.request.path) - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - local = env.params.query["local"]?.try &.== "true" - - env.response.content_type = "application/x-mpegURL" - env.response.headers.add("Access-Control-Allow-Origin", "*") - - manifest = response.body - - if local - manifest = manifest.gsub("https://www.youtube.com", HOST_URL) - manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true") - end - - manifest -end - -get "/api/manifest/hls_playlist/*" do |env| - response = YT_POOL.client &.get(env.request.path) - - if response.status_code != 200 - env.response.status_code = response.status_code - next - end - - local = env.params.query["local"]?.try &.== "true" - - env.response.content_type = "application/x-mpegURL" - env.response.headers.add("Access-Control-Allow-Origin", "*") - - manifest = response.body - - if local - manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match| - path = URI.parse(match).path - - path = path.lchop("/videoplayback/") - path = path.rchop("/") - - path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| - mimetype = mimetype.split("/") - mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] - end - - path = path.split("/") - - raw_params = {} of String => Array(String) - path.each_slice(2) do |pair| - key, value = pair - value = URI.decode_www_form(value) - - if raw_params[key]? - raw_params[key] << value - else - raw_params[key] = [value] - end - end - - raw_params = HTTP::Params.new(raw_params) - if fvip = raw_params["hls_chunk_host"].match(/r(?\d+)---/) - raw_params["fvip"] = fvip["fvip"] - end - - raw_params["local"] = "true" - - "#{HOST_URL}/videoplayback?#{raw_params}" - end - end - - manifest -end - -# YouTube /videoplayback links expire after 6 hours, -# so we have a mechanism here to redirect to the latest version -get "/latest_version" do |env| - if env.params.query["download_widget"]? - download_widget = JSON.parse(env.params.query["download_widget"]) - - id = download_widget["id"].as_s - title = download_widget["title"].as_s - - if label = download_widget["label"]? - env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}" - next - else - itag = download_widget["itag"].as_s.to_i - local = "true" - end - end - - id ||= env.params.query["id"]? - itag ||= env.params.query["itag"]?.try &.to_i - - region = env.params.query["region"]? - - local ||= env.params.query["local"]? - local ||= "false" - local = local == "true" - - if !id || !itag - env.response.status_code = 400 - next - end - - video = get_video(id, PG_DB, region: region) - - fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } - url = fmt.try &.["url"]?.try &.as_s - - if !url - env.response.status_code = 404 - next - end - - url = URI.parse(url).request_target.not_nil! if local - url = "#{url}&title=#{title}" if title - - env.redirect url -end - -options "/videoplayback" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -options "/videoplayback/*" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -options "/api/manifest/dash/id/videoplayback" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -options "/api/manifest/dash/id/videoplayback/*" do |env| - env.response.headers.delete("Content-Type") - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -get "/videoplayback/*" do |env| - path = env.request.path - - path = path.lchop("/videoplayback/") - path = path.rchop("/") - - path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| - mimetype = mimetype.split("/") - mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] - end - - path = path.split("/") - - raw_params = {} of String => Array(String) - path.each_slice(2) do |pair| - key, value = pair - value = URI.decode_www_form(value) - - if raw_params[key]? - raw_params[key] << value - else - raw_params[key] = [value] - end - end - - query_params = HTTP::Params.new(raw_params) - - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.redirect "/videoplayback?#{query_params}" -end - -get "/videoplayback" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - query_params = env.params.query - - fvip = query_params["fvip"]? || "3" - mns = query_params["mn"]?.try &.split(",") - mns ||= [] of String - - if query_params["region"]? - region = query_params["region"] - query_params.delete("region") - end - - if query_params["host"]? && !query_params["host"].empty? - host = "https://#{query_params["host"]}" - query_params.delete("host") - else - host = "https://r#{fvip}---#{mns.pop}.googlevideo.com" - end - - url = "/videoplayback?#{query_params.to_s}" - - headers = HTTP::Headers.new - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - client = make_client(URI.parse(host), region) - response = HTTP::Client::Response.new(500) - error = "" - 5.times do - begin - response = client.head(url, headers) - - if response.headers["Location"]? - location = URI.parse(response.headers["Location"]) - env.response.headers["Access-Control-Allow-Origin"] = "*" - - new_host = "#{location.scheme}://#{location.host}" - if new_host != host - host = new_host - client.close - client = make_client(URI.parse(new_host), region) - end - - url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" - else - break - end - rescue Socket::Addrinfo::Error - if !mns.empty? - mn = mns.pop - end - fvip = "3" - - host = "https://r#{fvip}---#{mn}.googlevideo.com" - client = make_client(URI.parse(host), region) - rescue ex - error = ex.message - end - end - - if response.status_code >= 400 - env.response.status_code = response.status_code - env.response.content_type = "text/plain" - next error - end - - if url.includes? "&file=seg.ts" - if CONFIG.disabled?("livestreams") - next error_template(403, "Administrator has disabled this endpoint.") - end - - begin - client.get(url, headers) do |response| - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if location = response.headers["Location"]? - location = URI.parse(location) - location = "#{location.request_target}&host=#{location.host}" - - if region - location += "®ion=#{region}" - end - - next env.redirect location - end - - IO.copy(response.body_io, env.response) - end - rescue ex - end - else - if query_params["title"]? && CONFIG.disabled?("downloads") || - CONFIG.disabled?("dash") - next error_template(403, "Administrator has disabled this endpoint.") - end - - content_length = nil - first_chunk = true - range_start, range_end = parse_range(env.request.headers["Range"]?) - chunk_start = range_start - chunk_end = range_end - - if !chunk_end || chunk_end - chunk_start > HTTP_CHUNK_SIZE - chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1 - end - - # TODO: Record bytes written so we can restart after a chunk fails - while true - if !range_end && content_length - range_end = content_length - end - - if range_end && chunk_start > range_end - break - end - - if range_end && chunk_end > range_end - chunk_end = range_end - end - - headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}" - - begin - client.get(url, headers) do |response| - if first_chunk - if !env.request.headers["Range"]? && response.status_code == 206 - env.response.status_code = 200 - else - env.response.status_code = response.status_code - end - - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "content-range" - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if location = response.headers["Location"]? - location = URI.parse(location) - location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" - - env.redirect location - break - end - - if title = query_params["title"]? - # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" - end - - if !response.headers.includes_word?("Transfer-Encoding", "chunked") - content_length = response.headers["Content-Range"].split("/")[-1].to_i64 - if env.request.headers["Range"]? - env.response.headers["Content-Range"] = "bytes #{range_start}-#{range_end || (content_length - 1)}/#{content_length}" - env.response.content_length = ((range_end.try &.+ 1) || content_length) - range_start - else - env.response.content_length = content_length - end - end - end - - proxy_file(response, env) - end - rescue ex - if ex.message != "Error reading socket: Connection reset by peer" - break - else - client.close - client = make_client(URI.parse(host), region) - end - end - - chunk_start = chunk_end + 1 - chunk_end += HTTP_CHUNK_SIZE - first_chunk = false - end - end - client.close -end - get "/ggpht/*" do |env| url = env.request.path.lchop("/ggpht") diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index a6ab4015..70623cc0 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -185,6 +185,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) if !author raise InfoException.new("Deleted or invalid channel") end + author = author.content # Auto-generated channels diff --git a/src/invidious/helpers/macros.cr b/src/invidious/helpers/macros.cr index 5d426a8b..75df1612 100644 --- a/src/invidious/helpers/macros.cr +++ b/src/invidious/helpers/macros.cr @@ -56,3 +56,12 @@ end macro rendered(filename) render "src/invidious/views/#{{{filename}}}.ecr" end + +# Similar to Kemals halt method but works in a +# method. +macro haltf(env, status_code = 200, response = "") + {{env}}.response.status_code = {{status_code}} + {{env}}.response.print {{response}} + {{env}}.response.close + return +end diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr new file mode 100644 index 00000000..31e1a123 --- /dev/null +++ b/src/invidious/routes/api/manifest.cr @@ -0,0 +1,237 @@ +module Invidious::Routes::APIManifest + # /api/manifest/dash/id/:id + def self.get_dash_video_id(env) + env.response.headers.add("Access-Control-Allow-Origin", "*") + env.response.content_type = "application/dash+xml" + + local = env.params.query["local"]?.try &.== "true" + id = env.params.url["id"] + region = env.params.query["region"]? + + # Since some implementations create playlists based on resolution regardless of different codecs, + # we can opt to only add a source to a representation if it has a unique height within that representation + unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } + + begin + video = get_video(id, PG_DB, region: region) + rescue ex : VideoRedirect + return env.redirect env.request.resource.gsub(id, ex.video_id) + rescue ex + haltf env, status_code: 403 + end + + if dashmpd = video.dash_manifest_url + manifest = YT_POOL.client &.get(URI.parse(dashmpd).request_target).body + + manifest = manifest.gsub(/[^<]+<\/BaseURL>/) do |baseurl| + url = baseurl.lchop("") + url = url.rchop("") + + if local + uri = URI.parse(url) + url = "#{uri.request_target}host/#{uri.host}/" + end + + "#{url}" + end + + return manifest + end + + adaptive_fmts = video.adaptive_fmts + + if local + adaptive_fmts.each do |fmt| + fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) + end + end + + audio_streams = video.audio_streams + video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse + + manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml| + xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", + "profiles": "urn:mpeg:dash:profile:full:2011", minBufferTime: "PT1.5S", type: "static", + mediaPresentationDuration: "PT#{video.length_seconds}S") do + xml.element("Period") do + i = 0 + + {"audio/mp4", "audio/webm"}.each do |mime_type| + mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } + next if mime_streams.empty? + + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do + mime_streams.each do |fmt| + codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') + bandwidth = fmt["bitrate"].as_i + itag = fmt["itag"].as_i + url = fmt["url"].as_s + + xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do + xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", + value: "2") + xml.element("BaseURL") { xml.text url } + xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do + xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") + end + end + end + end + + i += 1 + end + + potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} + + {"video/mp4", "video/webm"}.each do |mime_type| + mime_streams = video_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } + next if mime_streams.empty? + + heights = [] of Int32 + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do + mime_streams.each do |fmt| + codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') + bandwidth = fmt["bitrate"].as_i + itag = fmt["itag"].as_i + url = fmt["url"].as_s + width = fmt["width"].as_i + height = fmt["height"].as_i + + # Resolutions reported by YouTube player (may not accurately reflect source) + height = potential_heights.min_by { |i| (height - i).abs } + next if unique_res && heights.includes? height + heights << height + + xml.element("Representation", id: itag, codecs: codecs, width: width, height: height, + startWithSAP: "1", maxPlayoutRate: "1", + bandwidth: bandwidth, frameRate: fmt["fps"]) do + xml.element("BaseURL") { xml.text url } + xml.element("SegmentBase", indexRange: "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}") do + xml.element("Initialization", range: "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}") + end + end + end + end + + i += 1 + end + end + end + end + + return manifest + end + + # /api/manifest/dash/id/videoplayback + def self.get_dash_video_playback(env) + env.response.headers.delete("Content-Type") + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.redirect "/videoplayback?#{env.params.query}" + end + + # /api/manifest/dash/id/videoplayback/* + def self.get_dash_video_playback_greedy(env) + env.response.headers.delete("Content-Type") + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.redirect env.request.path.lchop("/api/manifest/dash/id") + end + + # /api/manifest/dash/id/videoplayback && /api/manifest/dash/id/videoplayback/* + def self.options_dash_video_playback(env) + env.response.headers.delete("Content-Type") + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" + end + + # /api/manifest/hls_playlist/* + def self.get_hls_playlist(env) + response = YT_POOL.client &.get(env.request.path) + + if response.status_code != 200 + haltf env, status_code: response.status_code + end + + local = env.params.query["local"]?.try &.== "true" + + env.response.content_type = "application/x-mpegURL" + env.response.headers.add("Access-Control-Allow-Origin", "*") + + manifest = response.body + + if local + manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match| + path = URI.parse(match).path + + path = path.lchop("/videoplayback/") + path = path.rchop("/") + + path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| + mimetype = mimetype.split("/") + mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] + end + + path = path.split("/") + + raw_params = {} of String => Array(String) + path.each_slice(2) do |pair| + key, value = pair + value = URI.decode_www_form(value) + + if raw_params[key]? + raw_params[key] << value + else + raw_params[key] = [value] + end + end + + raw_params = HTTP::Params.new(raw_params) + if fvip = raw_params["hls_chunk_host"].match(/r(?\d+)---/) + raw_params["fvip"] = fvip["fvip"] + end + + raw_params["local"] = "true" + + "#{HOST_URL}/videoplayback?#{raw_params}" + end + end + + manifest + end + + # /api/manifest/hls_variant/* + def self.get_hls_variant(env) + response = YT_POOL.client &.get(env.request.path) + + if response.status_code != 200 + haltf env, status_code: response.status_code + end + + local = env.params.query["local"]?.try &.== "true" + + env.response.content_type = "application/x-mpegURL" + env.response.headers.add("Access-Control-Allow-Origin", "*") + + manifest = response.body + + if local + manifest = manifest.gsub("https://www.youtube.com", HOST_URL) + manifest = manifest.gsub("index.m3u8", "index.m3u8?local=true") + end + + manifest + end +end + +macro define_api_manifest_routes + Invidious::Routing.get "/api/manifest/dash/id/:id", Invidious::Routes::APIManifest, :get_dash_video_id + + Invidious::Routing.get "/api/manifest/dash/id/videoplayback", Invidious::Routes::APIManifest, :get_dash_video_playback + Invidious::Routing.get "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::APIManifest, :get_dash_video_playback_greedy + + Invidious::Routing.options "/api/manifest/dash/id/videoplayback", Invidious::Routes::APIManifest, :options_dash_video_playback + Invidious::Routing.options "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::APIManifest, :options_dash_video_playback + + Invidious::Routing.get "/api/manifest/hls_playlist/*", Invidious::Routes::APIManifest, :get_hls_playlist + Invidious::Routing.get "/api/manifest/hls_variant/*", Invidious::Routes::APIManifest, :get_hls_variant +end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr new file mode 100644 index 00000000..a8b06bf7 --- /dev/null +++ b/src/invidious/routes/api/v1/channels.cr @@ -0,0 +1,243 @@ +module Invidious::Routes::APIv1 + def self.home(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + sort_by = env.params.query["sort_by"]?.try &.downcase + sort_by ||= "newest" + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) + end + + page = 1 + if channel.auto_generated + videos = [] of SearchVideo + count = 0 + else + begin + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + rescue ex + return error_json(500, ex) + end + end + + JSON.build do |json| + # TODO: Refactor into `to_json` for InvidiousChannel + json.object do + json.field "author", channel.author + json.field "authorId", channel.ucid + json.field "authorUrl", channel.author_url + + json.field "authorBanners" do + json.array do + if channel.banner + qualities = { + {width: 2560, height: 424}, + {width: 2120, height: 351}, + {width: 1060, height: 175}, + } + qualities.each do |quality| + json.object do + json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-") + json.field "width", quality[:width] + json.field "height", quality[:height] + end + end + + json.object do + json.field "url", channel.banner.not_nil!.split("=w1060-")[0] + json.field "width", 512 + json.field "height", 288 + end + end + end + end + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "subCount", channel.sub_count + json.field "totalViews", channel.total_views + json.field "joined", channel.joined.to_unix + + json.field "autoGenerated", channel.auto_generated + json.field "isFamilyFriendly", channel.is_family_friendly + json.field "description", html_to_content(channel.description_html) + json.field "descriptionHtml", channel.description_html + + json.field "allowedRegions", channel.allowed_regions + + json.field "latestVideos" do + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + + json.field "relatedChannels" do + json.array do + channel.related_channels.each do |related_channel| + json.object do + json.field "author", related_channel.author + json.field "authorId", related_channel.ucid + json.field "authorUrl", related_channel.author_url + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + end + end + end + end + end + end + end + + def self.latest(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + begin + videos = get_latest_videos(ucid) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + + def self.videos(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + sort_by = env.params.query["sort"]?.try &.downcase + sort_by ||= env.params.query["sort_by"]?.try &.downcase + sort_by ||= "newest" + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) + end + + begin + count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + + def self.playlists(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort"]?.try &.downcase || + env.params.query["sort_by"]?.try &.downcase || + "last" + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) + return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex + return error_json(500, ex) + end + + items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", continuation + end + end + end + + def self.community(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + continuation = env.params.query["continuation"]? + # sort_by = env.params.query["sort_by"]?.try &.downcase + + begin + fetch_channel_community(ucid, continuation, locale, format, thin_mode) + rescue ex + return error_json(500, ex) + end + end +end diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr new file mode 100644 index 00000000..c24266c6 --- /dev/null +++ b/src/invidious/routes/api/v1/feeds.cr @@ -0,0 +1,46 @@ +module Invidious::Routes::APIv1 + def self.trending(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + region = env.params.query["region"]? + trending_type = env.params.query["type"]? + + begin + trending, plid = fetch_trending(trending_type, region, locale) + rescue ex + return error_json(500, ex) + end + + videos = JSON.build do |json| + json.array do + trending.each do |video| + video.to_json(locale, json) + end + end + end + + videos + end + + def self.popular(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + if !CONFIG.popular_enabled + error_message = {"error" => "Administrator has disabled this endpoint."}.to_json + env.response.status_code = 400 + return error_message + end + + JSON.build do |json| + json.array do + popular_videos.each do |video| + video.to_json(locale, json) + end + end + end + end +end diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr new file mode 100644 index 00000000..4bf8b8b0 --- /dev/null +++ b/src/invidious/routes/api/v1/misc.cr @@ -0,0 +1,13 @@ +module Invidious::Routes::APIv1 + # Stats API endpoint for Invidious + def self.stats(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + env.response.content_type = "application/json" + + if !CONFIG.statistics_enabled + return error_json(400, "Statistics are not enabled.") + end + + Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json + end +end diff --git a/src/invidious/routes/api/v1/routes.cr b/src/invidious/routes/api/v1/routes.cr new file mode 100644 index 00000000..5c61ed7c --- /dev/null +++ b/src/invidious/routes/api/v1/routes.cr @@ -0,0 +1,37 @@ +# There is far too many API routes to define in invidious.cr +# so we'll just do it here instead with a macro. +macro define_v1_api_routes(base_url = "/api/v1") + Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1, :stats + + # Widgets + Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::APIv1, :storyboards + Invidious::Routing.get "#{{{base_url}}}/captions/:id", Invidious::Routes::APIv1, :captions + Invidious::Routing.get "#{{{base_url}}}/annotations/:id", Invidious::Routes::APIv1, :annotations + Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::APIv1, :search_suggestions + Invidious::Routing.get "#{{{base_url}}}/comments/:id", Invidious::Routes::APIv1, :comments + + # Feeds + Invidious::Routing.get "#{{{base_url}}}/trending", Invidious::Routes::APIv1, :trending + Invidious::Routing.get "#{{{base_url}}}/popular", Invidious::Routes::APIv1, :popular + + # Channels + Invidious::Routing.get "#{{{base_url}}}/channels/:ucid", Invidious::Routes::APIv1, :home + {% for route in { + {"home", "home"}, + {"videos", "videos"}, + {"latest", "latest"}, + {"playlists", "playlists"}, + {"comments", "community"}, # Why is the route for the community API `comments`?, + {"search", "channel_search"}, + } %} + + Invidious::Routing.get "#{{{base_url}}}/channels/#{{{route[0]}}}/:ucid", Invidious::Routes::APIv1, :{{route[1]}} + Invidious::Routing.get "#{{{base_url}}}/channels/:ucid/#{{{route[0]}}}", Invidious::Routes::APIv1, :{{route[1]}} + {% end %} + + # Search + Invidious::Routing.get "#{{{base_url}}}/search", Invidious::Routes::APIv1, :search + Invidious::Routing.get "#{{{base_url}}}/videos/:id", Invidious::Routes::APIv1, :videos + Invidious::Routing.get "#{{{base_url}}}/search", Invidious::Routes::APIv1, :search + +end diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr new file mode 100644 index 00000000..d1ed645d --- /dev/null +++ b/src/invidious/routes/api/v1/search.cr @@ -0,0 +1,101 @@ +module Invidious::Routes::APIv1 + def self.search(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + query = env.params.query["q"]? + query ||= "" + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + sort_by = env.params.query["sort_by"]?.try &.downcase + sort_by ||= "relevance" + + date = env.params.query["date"]?.try &.downcase + date ||= "" + + duration = env.params.query["duration"]?.try &.downcase + duration ||= "" + + features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase } + features ||= [] of String + + content_type = env.params.query["type"]?.try &.downcase + content_type ||= "video" + + begin + search_params = produce_search_params(page, sort_by, date, content_type, duration, features) + rescue ex + return error_json(400, ex) + end + + count, search_results = search(query, search_params, region).as(Tuple) + JSON.build do |json| + json.array do + search_results.each do |item| + item.to_json(locale, json) + end + end + end + end + + def self.channel_search(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + query = env.params.query["q"]? + query ||= "" + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + count, search_results = channel_search(query, page, ucid) + JSON.build do |json| + json.array do + search_results.each do |item| + item.to_json(locale, json) + end + end + end + end + + def self.search_suggestions(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + query = env.params.query["q"]? + query ||= "" + + begin + headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} + response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body + + body = response[35..-2] + body = JSON.parse(body).as_a + suggestions = body[1].as_a[0..-2] + + JSON.build do |json| + json.object do + json.field "query", body[0].as_s + json.field "suggestions" do + json.array do + suggestions.each do |suggestion| + json.string suggestion[0].as_s + end + end + end + end + end + rescue ex + return error_json(500, ex) + end + end +end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr new file mode 100644 index 00000000..7b7433f2 --- /dev/null +++ b/src/invidious/routes/api/v1/videos.cr @@ -0,0 +1,372 @@ +module Invidious::Routes::APIv1 + def self.videos(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + begin + video = get_video(id, PG_DB, region: region) + rescue ex : VideoRedirect + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) + rescue ex + return error_json(500, ex) + end + + video.to_json(locale) + end + + def self.captions(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 + # It is possible to use `/api/timedtext?type=list&v=#{id}` and + # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly, + # but this does not provide links for auto-generated captions. + # + # In future this should be investigated as an alternative, since it does not require + # getting video info. + + begin + video = get_video(id, PG_DB, region: region) + rescue ex : VideoRedirect + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) + rescue ex + env.response.status_code = 500 + return + end + + captions = video.captions + + label = env.params.query["label"]? + lang = env.params.query["lang"]? + tlang = env.params.query["tlang"]? + + if !label && !lang + response = JSON.build do |json| + json.object do + json.field "captions" do + json.array do + captions.each do |caption| + json.object do + json.field "label", caption.name + json.field "languageCode", caption.languageCode + json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" + end + end + end + end + end + end + + return response + end + + env.response.content_type = "text/vtt; charset=UTF-8" + + if lang + caption = captions.select { |caption| caption.languageCode == lang } + else + caption = captions.select { |caption| caption.name == label } + end + + if caption.empty? + env.response.status_code = 404 + return + else + caption = caption[0] + end + + url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target + + # Auto-generated captions often have cues that aren't aligned properly with the video, + # as well as some other markup that makes it cumbersome, so we try to fix that here + if caption.name.includes? "auto-generated" + caption_xml = YT_POOL.client &.get(url).body + caption_xml = XML.parse(caption_xml) + + webvtt = String.build do |str| + str << <<-END_VTT + WEBVTT + Kind: captions + Language: #{tlang || caption.languageCode} + + + END_VTT + + caption_nodes = caption_xml.xpath_nodes("//transcript/text") + caption_nodes.each_with_index do |node, i| + start_time = node["start"].to_f.seconds + duration = node["dur"]?.try &.to_f.seconds + duration ||= start_time + + if caption_nodes.size > i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration + end + + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + + text = HTML.unescape(node.content) + text = text.gsub(//, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?.*) : (?.*)/) + text = "#{md["text"]}" + end + + str << <<-END_CUE + #{start_time} --> #{end_time} + #{text} + + + END_CUE + end + end + else + webvtt = YT_POOL.client &.get("#{url}&format=vtt").body + end + + if title = env.params.query["title"]? + # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ + env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" + end + + webvtt + end + + # Fetches YouTube storyboards + # + # Which are sprites containing x * y preview + # thumbnails for individual scenes in a video. + # See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails + def self.storyboards(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + region = env.params.query["region"]? + + begin + video = get_video(id, PG_DB, region: region) + rescue ex : VideoRedirect + env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) + return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) + rescue ex + env.response.status_code = 500 + return + end + + storyboards = video.storyboards + width = env.params.query["width"]? + height = env.params.query["height"]? + + if !width && !height + response = JSON.build do |json| + json.object do + json.field "storyboards" do + generate_storyboards(json, id, storyboards) + end + end + end + + return response + end + + env.response.content_type = "text/vtt" + + storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" } + + if storyboard.empty? + env.response.status_code = 404 + return + else + storyboard = storyboard[0] + end + + String.build do |str| + str << <<-END_VTT + WEBVTT + END_VTT + + start_time = 0.milliseconds + end_time = storyboard[:interval].milliseconds + + storyboard[:storyboard_count].times do |i| + url = storyboard[:url] + authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? + url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") + url = "#{HOST_URL}/sb/#{authority}/#{url}" + + storyboard[:storyboard_height].times do |j| + storyboard[:storyboard_width].times do |k| + str << <<-END_CUE + #{start_time}.000 --> #{end_time}.000 + #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} + + + END_CUE + + start_time += storyboard[:interval].milliseconds + end_time += storyboard[:interval].milliseconds + end + end + end + end + end + + def self.annotations(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "text/xml" + + id = env.params.url["id"] + source = env.params.query["source"]? + source ||= "archive" + + if !id.match(/[a-zA-Z0-9_-]{11}/) + env.response.status_code = 400 + return + end + + annotations = "" + + case source + when "archive" + if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) + annotations = cached_annotation.annotations + else + index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') + + # IA doesn't handle leading hyphens, + # so we use https://archive.org/details/youtubeannotations_64 + if index == "62" + index = "64" + id = id.sub(/^-/, 'A') + end + + file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") + + location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) + + if !location.headers["Location"]? + env.response.status_code = location.status_code + end + + response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) + + if response.body.empty? + env.response.status_code = 404 + return + end + + if response.status_code != 200 + env.response.status_code = response.status_code + return + end + + annotations = response.body + + cache_annotation(PG_DB, id, annotations) + end + else # "youtube" + response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") + + if response.status_code != 200 + env.response.status_code = response.status_code + return + end + + annotations = response.body + end + + etag = sha256(annotations)[0, 16] + if env.request.headers["If-None-Match"]?.try &.== etag + env.response.status_code = 304 + else + env.response.headers["ETag"] = etag + annotations + end + end + + def self.comments(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + + source = env.params.query["source"]? + source ||= "youtube" + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + action = env.params.query["action"]? + action ||= "action_get_comments" + + continuation = env.params.query["continuation"]? + sort_by = env.params.query["sort_by"]?.try &.downcase + + if source == "youtube" + sort_by ||= "top" + + begin + comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) + rescue ex + return error_json(500, ex) + end + + return comments + elsif source == "reddit" + sort_by ||= "confidence" + + begin + comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) + content_html = template_reddit_comments(comments, locale) + + content_html = fill_links(content_html, "https", "www.reddit.com") + content_html = replace_links(content_html) + rescue ex + comments = nil + reddit_thread = nil + content_html = "" + end + + if !reddit_thread || !comments + env.response.status_code = 404 + return + end + + if format == "json" + reddit_thread = JSON.parse(reddit_thread.to_json).as_h + reddit_thread["comments"] = JSON.parse(comments.to_json) + + return reddit_thread.to_json + else + response = { + "title" => reddit_thread.title, + "permalink" => reddit_thread.permalink, + "contentHtml" => content_html, + } + + return response.to_json + end + end + end +end diff --git a/src/invidious/routes/api_v1/channels.cr b/src/invidious/routes/api_v1/channels.cr deleted file mode 100644 index 03ebebfb..00000000 --- a/src/invidious/routes/api_v1/channels.cr +++ /dev/null @@ -1,244 +0,0 @@ -module Invidious::Routes::APIv1 - def self.home(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - return error_json(500, ex) - end - - page = 1 - if channel.auto_generated - videos = [] of SearchVideo - count = 0 - else - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - return error_json(500, ex) - end - end - - JSON.build do |json| - # TODO: Refactor into `to_json` for InvidiousChannel - json.object do - json.field "author", channel.author - json.field "authorId", channel.ucid - json.field "authorUrl", channel.author_url - - json.field "authorBanners" do - json.array do - if channel.banner - qualities = { - {width: 2560, height: 424}, - {width: 2120, height: 351}, - {width: 1060, height: 175}, - } - qualities.each do |quality| - json.object do - json.field "url", channel.banner.not_nil!.gsub("=w1060-", "=w#{quality[:width]}-") - json.field "width", quality[:width] - json.field "height", quality[:height] - end - end - - json.object do - json.field "url", channel.banner.not_nil!.split("=w1060-")[0] - json.field "width", 512 - json.field "height", 288 - end - end - end - end - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", channel.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "subCount", channel.sub_count - json.field "totalViews", channel.total_views - json.field "joined", channel.joined.to_unix - json.field "paid", channel.paid - - json.field "autoGenerated", channel.auto_generated - json.field "isFamilyFriendly", channel.is_family_friendly - json.field "description", html_to_content(channel.description_html) - json.field "descriptionHtml", channel.description_html - - json.field "allowedRegions", channel.allowed_regions - - json.field "latestVideos" do - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - - json.field "relatedChannels" do - json.array do - channel.related_channels.each do |related_channel| - json.object do - json.field "author", related_channel.author - json.field "authorId", related_channel.ucid - json.field "authorUrl", related_channel.author_url - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - end - end - end - end - end - end - end - - def self.latest(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - begin - videos = get_latest_videos(ucid) - rescue ex - return error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end - - def self.videos(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - sort_by = env.params.query["sort"]?.try &.downcase - sort_by ||= env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - return error_json(500, ex) - end - - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - return error_json(500, ex) - end - - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end - - def self.playlists(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort"]?.try &.downcase || - env.params.query["sort_by"]?.try &.downcase || - "last" - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex - return error_json(500, ex) - end - - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) - - JSON.build do |json| - json.object do - json.field "playlists" do - json.array do - items.each do |item| - item.to_json(locale, json) if item.is_a?(SearchPlaylist) - end - end - end - - json.field "continuation", continuation - end - end - end - - def self.community(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - continuation = env.params.query["continuation"]? - # sort_by = env.params.query["sort_by"]?.try &.downcase - - begin - fetch_channel_community(ucid, continuation, locale, format, thin_mode) - rescue ex - return error_json(500, ex) - end - end -end diff --git a/src/invidious/routes/api_v1/feeds.cr b/src/invidious/routes/api_v1/feeds.cr deleted file mode 100644 index c24266c6..00000000 --- a/src/invidious/routes/api_v1/feeds.cr +++ /dev/null @@ -1,46 +0,0 @@ -module Invidious::Routes::APIv1 - def self.trending(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - region = env.params.query["region"]? - trending_type = env.params.query["type"]? - - begin - trending, plid = fetch_trending(trending_type, region, locale) - rescue ex - return error_json(500, ex) - end - - videos = JSON.build do |json| - json.array do - trending.each do |video| - video.to_json(locale, json) - end - end - end - - videos - end - - def self.popular(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - if !CONFIG.popular_enabled - error_message = {"error" => "Administrator has disabled this endpoint."}.to_json - env.response.status_code = 400 - return error_message - end - - JSON.build do |json| - json.array do - popular_videos.each do |video| - video.to_json(locale, json) - end - end - end - end -end diff --git a/src/invidious/routes/api_v1/misc.cr b/src/invidious/routes/api_v1/misc.cr deleted file mode 100644 index 4bf8b8b0..00000000 --- a/src/invidious/routes/api_v1/misc.cr +++ /dev/null @@ -1,13 +0,0 @@ -module Invidious::Routes::APIv1 - # Stats API endpoint for Invidious - def self.stats(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.response.content_type = "application/json" - - if !CONFIG.statistics_enabled - return error_json(400, "Statistics are not enabled.") - end - - Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json - end -end diff --git a/src/invidious/routes/api_v1/routes.cr b/src/invidious/routes/api_v1/routes.cr deleted file mode 100644 index ec3d9dff..00000000 --- a/src/invidious/routes/api_v1/routes.cr +++ /dev/null @@ -1,30 +0,0 @@ -# There is far too many API routes to define in invidious.cr -# so we'll just do it here instead with a macro. -macro define_v1_api_routes(base_url = "/api/v1") - Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1, :stats - - Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::APIv1, :storyboards - Invidious::Routing.get "#{{{base_url}}}/captions/:id", Invidious::Routes::APIv1, :captions - Invidious::Routing.get "#{{{base_url}}}/annotations/:id", Invidious::Routes::APIv1, :annotations - Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::APIv1, :search_suggestions - - Invidious::Routing.get "#{{{base_url}}}/comments/:id", Invidious::Routes::APIv1, :comments - Invidious::Routing.get "#{{{base_url}}}/trending", Invidious::Routes::APIv1, :trending - Invidious::Routing.get "#{{{base_url}}}/popular", Invidious::Routes::APIv1, :popular - - Invidious::Routing.get "#{{{base_url}}}/channels/:ucid", Invidious::Routes::APIv1, :home - - {% for route in { - {"home", "home"}, - {"videos", "videos"}, - {"latest", "latest"}, - {"playlists", "playlists"}, - {"comments", "community"}, # Why is the route for the community API `comments`?, - {"search", "channel_search"}, - } %} - - Invidious::Routing.get "#{{{base_url}}}/channels/#{{{route[0]}}}/:ucid", Invidious::Routes::APIv1, :{{route[1]}} - Invidious::Routing.get "#{{{base_url}}}/channels/:ucid/#{{{route[0]}}}", Invidious::Routes::APIv1, :{{route[1]}} - - {% end %} -end diff --git a/src/invidious/routes/api_v1/search.cr b/src/invidious/routes/api_v1/search.cr deleted file mode 100644 index 61fdadd8..00000000 --- a/src/invidious/routes/api_v1/search.cr +++ /dev/null @@ -1,24 +0,0 @@ -module Invidious::Routes::APIv1 - def self.channel_search(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - query = env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - count, search_results = channel_search(query, page, ucid) - JSON.build do |json| - json.array do - search_results.each do |item| - item.to_json(locale, json) - end - end - end - end -end diff --git a/src/invidious/routes/api_v1/video_playback.cr b/src/invidious/routes/api_v1/video_playback.cr deleted file mode 100644 index 16942b22..00000000 --- a/src/invidious/routes/api_v1/video_playback.cr +++ /dev/null @@ -1,2 +0,0 @@ -module Invidious::Routes::APIv1 -end diff --git a/src/invidious/routes/api_v1/widgets.cr b/src/invidious/routes/api_v1/widgets.cr deleted file mode 100644 index 0b1cf67e..00000000 --- a/src/invidious/routes/api_v1/widgets.cr +++ /dev/null @@ -1,386 +0,0 @@ -module Invidious::Routes::APIv1 - # Fetches YouTube storyboards - # - # Which are sprites containing x * y preview - # thumbnails for individual scenes in a video. - # See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails - def self.storyboards(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - env.response.status_code = 500 - return - end - - storyboards = video.storyboards - width = env.params.query["width"]? - height = env.params.query["height"]? - - if !width && !height - response = JSON.build do |json| - json.object do - json.field "storyboards" do - generate_storyboards(json, id, storyboards) - end - end - end - - return response - end - - env.response.content_type = "text/vtt" - - storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" } - - if storyboard.empty? - env.response.status_code = 404 - return - else - storyboard = storyboard[0] - end - - String.build do |str| - str << <<-END_VTT - WEBVTT - END_VTT - - start_time = 0.milliseconds - end_time = storyboard[:interval].milliseconds - - storyboard[:storyboard_count].times do |i| - url = storyboard[:url] - authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? - url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") - url = "#{HOST_URL}/sb/#{authority}/#{url}" - - storyboard[:storyboard_height].times do |j| - storyboard[:storyboard_width].times do |k| - str << <<-END_CUE - #{start_time}.000 --> #{end_time}.000 - #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} - - - END_CUE - - start_time += storyboard[:interval].milliseconds - end_time += storyboard[:interval].milliseconds - end - end - end - end - end - - def self.captions(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - region = env.params.query["region"]? - - # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 - # It is possible to use `/api/timedtext?type=list&v=#{id}` and - # `/api/timedtext?type=track&v=#{id}&lang=#{lang_code}` directly, - # but this does not provide links for auto-generated captions. - # - # In future this should be investigated as an alternative, since it does not require - # getting video info. - - begin - video = get_video(id, PG_DB, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) - rescue ex - env.response.status_code = 500 - return - end - - captions = video.captions - - label = env.params.query["label"]? - lang = env.params.query["lang"]? - tlang = env.params.query["tlang"]? - - if !label && !lang - response = JSON.build do |json| - json.object do - json.field "captions" do - json.array do - captions.each do |caption| - json.object do - json.field "label", caption.name - json.field "languageCode", caption.languageCode - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" - end - end - end - end - end - end - - return response - end - - env.response.content_type = "text/vtt; charset=UTF-8" - - if lang - caption = captions.select { |caption| caption.languageCode == lang } - else - caption = captions.select { |caption| caption.name == label } - end - - if caption.empty? - env.response.status_code = 404 - return - else - caption = caption[0] - end - - url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target - - # Auto-generated captions often have cues that aren't aligned properly with the video, - # as well as some other markup that makes it cumbersome, so we try to fix that here - if caption.name.includes? "auto-generated" - caption_xml = YT_POOL.client &.get(url).body - caption_xml = XML.parse(caption_xml) - - webvtt = String.build do |str| - str << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || caption.languageCode} - - - END_VTT - - caption_nodes = caption_xml.xpath_nodes("//transcript/text") - caption_nodes.each_with_index do |node, i| - start_time = node["start"].to_f.seconds - duration = node["dur"]?.try &.to_f.seconds - duration ||= start_time - - if caption_nodes.size > i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration - end - - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - - text = HTML.unescape(node.content) - text = text.gsub(//, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?.*) : (?.*)/) - text = "#{md["text"]}" - end - - str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE - end - end - else - webvtt = YT_POOL.client &.get("#{url}&format=vtt").body - end - - if title = env.params.query["title"]? - # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" - end - - webvtt - end - - def self.annotations(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "text/xml" - - id = env.params.url["id"] - source = env.params.query["source"]? - source ||= "archive" - - if !id.match(/[a-zA-Z0-9_-]{11}/) - env.response.status_code = 400 - return - end - - annotations = "" - - case source - when "archive" - if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) - annotations = cached_annotation.annotations - else - index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') - - # IA doesn't handle leading hyphens, - # so we use https://archive.org/details/youtubeannotations_64 - if index == "62" - index = "64" - id = id.sub(/^-/, 'A') - end - - file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") - - location = make_client(ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) - - if !location.headers["Location"]? - env.response.status_code = location.status_code - end - - response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) - - if response.body.empty? - env.response.status_code = 404 - return - end - - if response.status_code != 200 - env.response.status_code = response.status_code - return - end - - annotations = response.body - - cache_annotation(PG_DB, id, annotations) - end - else # "youtube" - response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") - - if response.status_code != 200 - env.response.status_code = response.status_code - return - end - - annotations = response.body - end - - etag = sha256(annotations)[0, 16] - if env.request.headers["If-None-Match"]?.try &.== etag - env.response.status_code = 304 - else - env.response.headers["ETag"] = etag - annotations - end - end - - def self.search_suggestions(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - query = env.params.query["q"]? - query ||= "" - - begin - headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} - response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body - - body = response[35..-2] - body = JSON.parse(body).as_a - suggestions = body[1].as_a[0..-2] - - JSON.build do |json| - json.object do - json.field "query", body[0].as_s - json.field "suggestions" do - json.array do - suggestions.each do |suggestion| - json.string suggestion[0].as_s - end - end - end - end - end - rescue ex - return error_json(500, ex) - end - end - - def self.comments(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - region = env.params.query["region"]? - - env.response.content_type = "application/json" - - id = env.params.url["id"] - - source = env.params.query["source"]? - source ||= "youtube" - - thin_mode = env.params.query["thin_mode"]? - thin_mode = thin_mode == "true" - - format = env.params.query["format"]? - format ||= "json" - - action = env.params.query["action"]? - action ||= "action_get_comments" - - continuation = env.params.query["continuation"]? - sort_by = env.params.query["sort_by"]?.try &.downcase - - if source == "youtube" - sort_by ||= "top" - - begin - comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) - rescue ex - return error_json(500, ex) - end - - return comments - elsif source == "reddit" - sort_by ||= "confidence" - - begin - comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) - content_html = template_reddit_comments(comments, locale) - - content_html = fill_links(content_html, "https", "www.reddit.com") - content_html = replace_links(content_html) - rescue ex - comments = nil - reddit_thread = nil - content_html = "" - end - - if !reddit_thread || !comments - env.response.status_code = 404 - return - end - - if format == "json" - reddit_thread = JSON.parse(reddit_thread.to_json).as_h - reddit_thread["comments"] = JSON.parse(comments.to_json) - - return reddit_thread.to_json - else - response = { - "title" => reddit_thread.title, - "permalink" => reddit_thread.permalink, - "contentHtml" => content_html, - } - - return response.to_json - end - end - end -end diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr new file mode 100644 index 00000000..0fe2853d --- /dev/null +++ b/src/invidious/routes/video_playback.cr @@ -0,0 +1,290 @@ +module Invidious::Routes::VideoPlayback + # /videoplayback + def self.get_video_playback(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + query_params = env.params.query + + fvip = query_params["fvip"]? || "3" + mns = query_params["mn"]?.try &.split(",") + mns ||= [] of String + + if query_params["region"]? + region = query_params["region"] + query_params.delete("region") + end + + if query_params["host"]? && !query_params["host"].empty? + host = "https://#{query_params["host"]}" + query_params.delete("host") + else + host = "https://r#{fvip}---#{mns.pop}.googlevideo.com" + end + + url = "/videoplayback?#{query_params.to_s}" + + headers = HTTP::Headers.new + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + client = make_client(URI.parse(host), region) + response = HTTP::Client::Response.new(500) + error = "" + 5.times do + begin + response = client.head(url, headers) + + if response.headers["Location"]? + location = URI.parse(response.headers["Location"]) + env.response.headers["Access-Control-Allow-Origin"] = "*" + + new_host = "#{location.scheme}://#{location.host}" + if new_host != host + host = new_host + client.close + client = make_client(URI.parse(new_host), region) + end + + url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" + else + break + end + rescue Socket::Addrinfo::Error + if !mns.empty? + mn = mns.pop + end + fvip = "3" + + host = "https://r#{fvip}---#{mn}.googlevideo.com" + client = make_client(URI.parse(host), region) + rescue ex + error = ex.message + end + end + + if response.status_code >= 400 + env.response.content_type = "text/plain" + haltf env, response.status_code + end + + if url.includes? "&file=seg.ts" + if CONFIG.disabled?("livestreams") + return error_template(403, "Administrator has disabled this endpoint.") + end + + begin + client.get(url, headers) do |response| + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if location = response.headers["Location"]? + location = URI.parse(location) + location = "#{location.request_target}&host=#{location.host}" + + if region + location += "®ion=#{region}" + end + + return env.redirect location + end + + IO.copy(response.body_io, env.response) + end + rescue ex + end + else + if query_params["title"]? && CONFIG.disabled?("downloads") || + CONFIG.disabled?("dash") + return error_template(403, "Administrator has disabled this endpoint.") + end + + content_length = nil + first_chunk = true + range_start, range_end = parse_range(env.request.headers["Range"]?) + chunk_start = range_start + chunk_end = range_end + + if !chunk_end || chunk_end - chunk_start > HTTP_CHUNK_SIZE + chunk_end = chunk_start + HTTP_CHUNK_SIZE - 1 + end + + # TODO: Record bytes written so we can restart after a chunk fails + while true + if !range_end && content_length + range_end = content_length + end + + if range_end && chunk_start > range_end + break + end + + if range_end && chunk_end > range_end + chunk_end = range_end + end + + headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}" + + begin + client.get(url, headers) do |response| + if first_chunk + if !env.request.headers["Range"]? && response.status_code == 206 + env.response.status_code = 200 + else + env.response.status_code = response.status_code + end + + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "content-range" + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if location = response.headers["Location"]? + location = URI.parse(location) + location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" + + env.redirect location + break + end + + if title = query_params["title"]? + # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ + env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" + end + + if !response.headers.includes_word?("Transfer-Encoding", "chunked") + content_length = response.headers["Content-Range"].split("/")[-1].to_i64 + if env.request.headers["Range"]? + env.response.headers["Content-Range"] = "bytes #{range_start}-#{range_end || (content_length - 1)}/#{content_length}" + env.response.content_length = ((range_end.try &.+ 1) || content_length) - range_start + else + env.response.content_length = content_length + end + end + end + + proxy_file(response, env) + end + rescue ex + if ex.message != "Error reading socket: Connection reset by peer" + break + else + client.close + client = make_client(URI.parse(host), region) + end + end + + chunk_start = chunk_end + 1 + chunk_end += HTTP_CHUNK_SIZE + first_chunk = false + end + end + client.close + end + + # /videoplayback/* + def self.get_video_playback_greedy(env) + path = env.request.path + + path = path.lchop("/videoplayback/") + path = path.rchop("/") + + path = path.gsub(/mime\/\w+\/\w+/) do |mimetype| + mimetype = mimetype.split("/") + mimetype[0] + "/" + mimetype[1] + "%2F" + mimetype[2] + end + + path = path.split("/") + + raw_params = {} of String => Array(String) + path.each_slice(2) do |pair| + key, value = pair + value = URI.decode_www_form(value) + + if raw_params[key]? + raw_params[key] << value + else + raw_params[key] = [value] + end + end + + query_params = HTTP::Params.new(raw_params) + + env.response.headers["Access-Control-Allow-Origin"] = "*" + return env.redirect "/videoplayback?#{query_params}" + end + + # /videoplayback/* && /videoplayback/* + def self.options_video_playback(env) + env.response.headers.delete("Content-Type") + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" + end + + # /latest_version + # + # YouTube /videoplayback links expire after 6 hours, + # so we have a mechanism here to redirect to the latest version + def self.latest_version(env) + if env.params.query["download_widget"]? + download_widget = JSON.parse(env.params.query["download_widget"]) + + id = download_widget["id"].as_s + title = download_widget["title"].as_s + + if label = download_widget["label"]? + return env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}" + else + itag = download_widget["itag"].as_s.to_i + local = "true" + end + end + + id ||= env.params.query["id"]? + itag ||= env.params.query["itag"]?.try &.to_i + + region = env.params.query["region"]? + + local ||= env.params.query["local"]? + local ||= "false" + local = local == "true" + + if !id || !itag + haltf env, status_code: 400, response: "TESTING" + end + + video = get_video(id, PG_DB, region: region) + + fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } + url = fmt.try &.["url"]?.try &.as_s + + if !url + haltf env, status_code: 404 + end + + url = URI.parse(url).request_target.not_nil! if local + url = "#{url}&title=#{title}" if title + + return env.redirect url + end +end + +macro define_video_playback_routes + Invidious::Routing.get "/videoplayback", Invidious::Routes::VideoPlayback, :get_video_playback + Invidious::Routing.get "/videoplayback/*", Invidious::Routes::VideoPlayback, :get_video_playback_greedy + + Invidious::Routing.options "/videoplayback", Invidious::Routes::VideoPlayback, :options_video_playback + Invidious::Routing.options "/videoplayback/*", Invidious::Routes::VideoPlayback, :options_video_playback + + Invidious::Routing.get "/latest_version", Invidious::Routes::VideoPlayback, :latest_version +end -- cgit v1.2.3 From b3426fdc94cd48412ab401636ff3b660fa75972f Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 13 Aug 2021 23:35:03 -0700 Subject: Restructure API routes to use more namespaces --- src/invidious/routes/api/v1/channels.cr | 25 ++++++++++++++++++++++++- src/invidious/routes/api/v1/feeds.cr | 2 +- src/invidious/routes/api/v1/misc.cr | 2 +- src/invidious/routes/api/v1/routes.cr | 30 ++++++++++++++---------------- src/invidious/routes/api/v1/search.cr | 25 +------------------------ src/invidious/routes/api/v1/videos.cr | 2 +- 6 files changed, 42 insertions(+), 44 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index a8b06bf7..3401232b 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIv1 +module Invidious::Routes::APIv1::Channels def self.home(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? @@ -240,4 +240,27 @@ module Invidious::Routes::APIv1 return error_json(500, ex) end end + + def self.channel_search(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + + query = env.params.query["q"]? + query ||= "" + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + count, search_results = channel_search(query, page, ucid) + JSON.build do |json| + json.array do + search_results.each do |item| + item.to_json(locale, json) + end + end + end + end end diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr index c24266c6..0107b71d 100644 --- a/src/invidious/routes/api/v1/feeds.cr +++ b/src/invidious/routes/api/v1/feeds.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIv1 +module Invidious::Routes::APIv1::Feeds def self.trending(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 4bf8b8b0..c7c32ca9 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIv1 +module Invidious::Routes::APIv1::Misc # Stats API endpoint for Invidious def self.stats(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? diff --git a/src/invidious/routes/api/v1/routes.cr b/src/invidious/routes/api/v1/routes.cr index 5c61ed7c..4f06bdb4 100644 --- a/src/invidious/routes/api/v1/routes.cr +++ b/src/invidious/routes/api/v1/routes.cr @@ -1,21 +1,21 @@ # There is far too many API routes to define in invidious.cr # so we'll just do it here instead with a macro. macro define_v1_api_routes(base_url = "/api/v1") - Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1, :stats + Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1::Misc, :stats - # Widgets - Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::APIv1, :storyboards - Invidious::Routing.get "#{{{base_url}}}/captions/:id", Invidious::Routes::APIv1, :captions - Invidious::Routing.get "#{{{base_url}}}/annotations/:id", Invidious::Routes::APIv1, :annotations - Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::APIv1, :search_suggestions - Invidious::Routing.get "#{{{base_url}}}/comments/:id", Invidious::Routes::APIv1, :comments + # Videos + Invidious::Routing.get "#{{{base_url}}}/videos/:id", Invidious::Routes::APIv1::Videos, :videos + Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::APIv1::Videos, :storyboards + Invidious::Routing.get "#{{{base_url}}}/captions/:id", Invidious::Routes::APIv1::Videos, :captions + Invidious::Routing.get "#{{{base_url}}}/annotations/:id", Invidious::Routes::APIv1::Videos, :annotations + Invidious::Routing.get "#{{{base_url}}}/comments/:id", Invidious::Routes::APIv1::Videos, :comments # Feeds - Invidious::Routing.get "#{{{base_url}}}/trending", Invidious::Routes::APIv1, :trending - Invidious::Routing.get "#{{{base_url}}}/popular", Invidious::Routes::APIv1, :popular + Invidious::Routing.get "#{{{base_url}}}/trending", Invidious::Routes::APIv1::Feeds, :trending + Invidious::Routing.get "#{{{base_url}}}/popular", Invidious::Routes::APIv1::Feeds, :popular # Channels - Invidious::Routing.get "#{{{base_url}}}/channels/:ucid", Invidious::Routes::APIv1, :home + Invidious::Routing.get "#{{{base_url}}}/channels/:ucid", Invidious::Routes::APIv1::Channels, :home {% for route in { {"home", "home"}, {"videos", "videos"}, @@ -25,13 +25,11 @@ macro define_v1_api_routes(base_url = "/api/v1") {"search", "channel_search"}, } %} - Invidious::Routing.get "#{{{base_url}}}/channels/#{{{route[0]}}}/:ucid", Invidious::Routes::APIv1, :{{route[1]}} - Invidious::Routing.get "#{{{base_url}}}/channels/:ucid/#{{{route[0]}}}", Invidious::Routes::APIv1, :{{route[1]}} + Invidious::Routing.get "#{{{base_url}}}/channels/#{{{route[0]}}}/:ucid", Invidious::Routes::APIv1::Channels, :{{route[1]}} + Invidious::Routing.get "#{{{base_url}}}/channels/:ucid/#{{{route[0]}}}", Invidious::Routes::APIv1::Channels, :{{route[1]}} {% end %} # Search - Invidious::Routing.get "#{{{base_url}}}/search", Invidious::Routes::APIv1, :search - Invidious::Routing.get "#{{{base_url}}}/videos/:id", Invidious::Routes::APIv1, :videos - Invidious::Routing.get "#{{{base_url}}}/search", Invidious::Routes::APIv1, :search - + Invidious::Routing.get "#{{{base_url}}}/search", Invidious::Routes::APIv1::Search, :search + Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::APIv1::Search, :search_suggestions end diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index d1ed645d..e4d5809f 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIv1 +module Invidious::Routes::APIv1::Search def self.search(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? region = env.params.query["region"]? @@ -42,29 +42,6 @@ module Invidious::Routes::APIv1 end end - def self.channel_search(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - query = env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - count, search_results = channel_search(query, page, ucid) - JSON.build do |json| - json.array do - search_results.each do |item| - item.to_json(locale, json) - end - end - end - end - def self.search_suggestions(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? region = env.params.query["region"]? diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 7b7433f2..0eb2fca3 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIv1 +module Invidious::Routes::APIv1::Videos def self.videos(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? -- cgit v1.2.3 From 39b34eece8e36c98f735df7d84a26d2aabedb348 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 14 Aug 2021 00:08:46 -0700 Subject: Extract API routes from invidious.cr (3/3) - Auth (excluding notifications*) APIs - Mixes *Notifications currently require the "connection_channel" channel for talking with the notifications job. Unfortunately, we cannot access that within the route modules yet. --- src/invidious.cr | 529 +-------------------------- src/invidious/routes/api/v1/authenticated.cr | 412 +++++++++++++++++++++ src/invidious/routes/api/v1/misc.cr | 123 +++++++ src/invidious/routes/api/v1/routes.cr | 36 +- 4 files changed, 573 insertions(+), 527 deletions(-) create mode 100644 src/invidious/routes/api/v1/authenticated.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 85852b9a..1962ae65 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -1639,132 +1639,12 @@ end end end -# API Endpoints - -{"/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"] - - offset = env.params.query["index"]?.try &.to_i? - offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 } - offset ||= 0 - - 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 = get_playlist(PG_DB, plid, locale) - rescue ex : InfoException - next error_json(404, ex) - rescue ex - next error_json(404, "Playlist does not exist.") - end - - user = env.get?("user").try &.as(User) - if !playlist || playlist.privacy.private? && playlist.author != user.try &.email - next error_json(404, "Playlist does not exist.") - end - - response = playlist.to_json(offset, locale, continuation: continuation) - - if format == "html" - response = JSON.parse(response) - playlist_html = template_playlist(response) - index, next_video = response["videos"].as_a.skip(1).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} - - response = { - "playlistHtml" => playlist_html, - "index" => index, - "nextVideo" => next_video, - }.to_json - end - - response - end -end - -get "/api/v1/mixes/:rdid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/json" - - rdid = env.params.url["rdid"] - - continuation = env.params.query["continuation"]? - continuation ||= rdid.lchop("RD")[0, 11] - - format = env.params.query["format"]? - format ||= "json" - - begin - mix = fetch_mix(rdid, continuation, locale: locale) - - if !rdid.ends_with? continuation - mix = fetch_mix(rdid, mix.videos[1].id) - index = mix.videos.index(mix.videos.select { |video| video.id == continuation }[0]?) - end - - mix.videos = mix.videos[index..-1] - rescue ex - next error_json(500, ex) - end - - response = JSON.build do |json| - json.object do - json.field "title", mix.title - json.field "mixId", mix.id - - json.field "videos" do - json.array do - mix.videos.each do |video| - json.object do - json.field "title", video.title - json.field "videoId", video.id - json.field "author", video.author - - json.field "authorId", video.ucid - json.field "authorUrl", "/channel/#{video.ucid}" - - json.field "videoThumbnails" do - json.array do - generate_thumbnails(json, video.id) - end - end - - json.field "index", video.index - json.field "lengthSeconds", video.length_seconds - end - end - end - end - end - end - - if format == "html" - response = JSON.parse(response) - playlist_html = template_mix(response) - next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] - - response = { - "playlistHtml" => playlist_html, - "nextVideo" => next_video, - }.to_json - end - - response -end - # Authenticated endpoints +# The notification APIs can't be extracted yet +# due to the requirement of the `connection_channel` +# used by the `NotificationJob` + get "/api/v1/auth/notifications" do |env| env.response.content_type = "text/event-stream" @@ -1783,407 +1663,6 @@ post "/api/v1/auth/notifications" do |env| create_notification_stream(env, topics, connection_channel) end -get "/api/v1/auth/preferences" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - user.preferences.to_json -end - -post "/api/v1/auth/preferences" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - begin - preferences = Preferences.from_json(env.request.body || "{}") - rescue - preferences = user.preferences - end - - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) - - env.response.status_code = 204 -end - -get "/api/v1/auth/feed" do |env| - env.response.content_type = "application/json" - - user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - max_results = env.params.query["max_results"]?.try &.to_i? - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) - - JSON.build do |json| - json.object do - json.field "notifications" do - json.array do - notifications.each do |video| - video.to_json(locale, json) - end - end - end - - json.field "videos" do - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end - end - end -end - -get "/api/v1/auth/subscriptions" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - if user.subscriptions.empty? - values = "'{}'" - else - values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}" - end - - subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) - - JSON.build do |json| - json.array do - subscriptions.each do |subscription| - json.object do - json.field "author", subscription.author - json.field "authorId", subscription.id - end - end - end - end -end - -post "/api/v1/auth/subscriptions/:ucid" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - ucid = env.params.url["ucid"] - - if !user.subscriptions.includes? ucid - get_channel(ucid, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email) - end - - # For Google accounts, access tokens don't have enough information to - # make a request on the user's behalf, which is why we don't sync with - # YouTube. - - env.response.status_code = 204 -end - -delete "/api/v1/auth/subscriptions/:ucid" do |env| - env.response.content_type = "application/json" - user = env.get("user").as(User) - - ucid = env.params.url["ucid"] - - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", ucid, user.email) - - 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, 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 - next error_json(400, "Invalid title.") - end - - privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } - if !privacy - next error_json(400, "Invalid privacy setting.") - end - - if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 - next error_json(400, "User cannot have more than 100 playlists.") - end - - 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? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - 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| - 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? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - 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? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - end - - if playlist.index.size >= 500 - next error_json(400, "Playlist cannot have more than 500 videos") - end - - video_id = env.params.json["videoId"].try &.as(String) - if !video_id - next error_json(403, "Invalid videoId") - end - - begin - video = get_video(video_id, PG_DB) - rescue ex - next error_json(500, ex) - 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 = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid) - - 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, index: playlist.index.size) -end - -delete "/api/v1/auth/playlists/:plid/videos/:index" 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"] - 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? - next error_json(404, "Playlist does not exist.") - end - - if playlist.author != user.email - next error_json(403, "Invalid user") - end - - if !playlist.index.includes? index - next error_json(404, "Playlist does not contain index") - 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 = cardinality(index) - 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) - scopes = env.get("scopes").as(Array(String)) - - tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time}) - - JSON.build do |json| - json.array do - tokens.each do |token| - json.object do - json.field "session", token[:session] - json.field "issued", token[:issued].to_unix - end - end - end - end -end - -post "/api/v1/auth/tokens/register" do |env| - user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - case env.request.headers["Content-Type"]? - when "application/x-www-form-urlencoded" - scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } - callback_url = env.params.body["callbackUrl"]? - expire = env.params.body["expire"]?.try &.to_i? - when "application/json" - scopes = env.params.json["scopes"].as(Array).map { |v| v.as_s } - callback_url = env.params.json["callbackUrl"]?.try &.as(String) - expire = env.params.json["expire"]?.try &.as(Int64) - else - next error_json(400, "Invalid or missing header 'Content-Type'") - end - - if callback_url && callback_url.empty? - callback_url = nil - end - - if callback_url - callback_url = URI.parse(callback_url) - end - - if sid = env.get?("sid").try &.as(String) - env.response.content_type = "text/html" - - csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true) - next templated "authorize_token" - else - env.response.content_type = "application/json" - - superset_scopes = env.get("scopes").as(Array(String)) - - authorized_scopes = [] of String - scopes.each do |scope| - if scopes_include_scope(superset_scopes, scope) - authorized_scopes << scope - end - end - - access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB) - - if callback_url - access_token = URI.encode_www_form(access_token) - - if query = callback_url.query - query = HTTP::Params.parse(query.not_nil!) - else - query = HTTP::Params.new - end - - query["token"] = access_token - callback_url.query = query.to_s - - env.redirect callback_url.to_s - else - access_token - end - end -end - -post "/api/v1/auth/tokens/unregister" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - env.response.content_type = "application/json" - user = env.get("user").as(User) - scopes = env.get("scopes").as(Array(String)) - - session = env.params.json["session"]?.try &.as(String) - session ||= env.get("session").as(String) - - # Allow tokens to revoke other tokens with correct scope - if session == env.get("session").as(String) - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) - elsif scopes_include_scope(scopes, "GET:tokens") - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) - else - next error_json(400, "Cannot revoke session #{session}") - end - - env.response.status_code = 204 -end - get "/ggpht/*" do |env| url = env.request.path.lchop("/ggpht") diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr new file mode 100644 index 00000000..4201f26d --- /dev/null +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -0,0 +1,412 @@ +module Invidious::Routes::APIv1::Authenticated + # def self.notifications(env) + # env.response.content_type = "text/event-stream" + + # topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000) + # topics ||= [] of String + + # create_notification_stream(env, topics, connection_channel) + # end + + def self.get_preferences(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + user.preferences.to_json + end + + def self.set_preferences(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + begin + preferences = Preferences.from_json(env.request.body || "{}") + rescue + preferences = user.preferences + end + + PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) + + env.response.status_code = 204 + end + + def self.feed(env) + env.response.content_type = "application/json" + + user = env.get("user").as(User) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + max_results = env.params.query["max_results"]?.try &.to_i? + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + + JSON.build do |json| + json.object do + json.field "notifications" do + json.array do + notifications.each do |video| + video.to_json(locale, json) + end + end + end + + json.field "videos" do + json.array do + videos.each do |video| + video.to_json(locale, json) + end + end + end + end + end + end + + def self.get_subscriptions(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + if user.subscriptions.empty? + values = "'{}'" + else + values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}" + end + + subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) + + JSON.build do |json| + json.array do + subscriptions.each do |subscription| + json.object do + json.field "author", subscription.author + json.field "authorId", subscription.id + end + end + end + end + end + + def self.subscribe_channel(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + ucid = env.params.url["ucid"] + + if !user.subscriptions.includes? ucid + get_channel(ucid, PG_DB, false, false) + PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email) + end + + # For Google accounts, access tokens don't have enough information to + # make a request on the user's behalf, which is why we don't sync with + # YouTube. + + env.response.status_code = 204 + end + + def self.unsubscribe_channel(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + ucid = env.params.url["ucid"] + + PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", ucid, user.email) + + env.response.status_code = 204 + end + + def self.list_playlists(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, json) + end + end + end + end + + def self.create_playlist(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 + return error_json(400, "Invalid title.") + end + + privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } + if !privacy + return error_json(400, "Invalid privacy setting.") + end + + if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + return error_json(400, "User cannot have more than 100 playlists.") + end + + 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 + + def self.update_playlist_attribute(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? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + 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 + + def self.delete_playlist(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? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + 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 + + def self.insert_video_into_playlist(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? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + end + + if playlist.index.size >= 500 + return error_json(400, "Playlist cannot have more than 500 videos") + end + + video_id = env.params.json["videoId"].try &.as(String) + if !video_id + return error_json(403, "Invalid videoId") + end + + begin + video = get_video(video_id, PG_DB) + rescue ex + return error_json(500, ex) + 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 = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid) + + 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, index: playlist.index.size) + end + + def self.delete_video_in_playlist(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"] + 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? + return error_json(404, "Playlist does not exist.") + end + + if playlist.author != user.email + return error_json(403, "Invalid user") + end + + if !playlist.index.includes? index + return error_json(404, "Playlist does not contain index") + 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 = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, plid) + + env.response.status_code = 204 + end + + # Invidious::Routing.patch "/api/v1/auth/playlists/:plid/videos/:index" + # def modify_playlist_at(env) + # TODO + # end + + def self.get_tokens(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + scopes = env.get("scopes").as(Array(String)) + + tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time}) + + JSON.build do |json| + json.array do + tokens.each do |token| + json.object do + json.field "session", token[:session] + json.field "issued", token[:issued].to_unix + end + end + end + end + end + + def self.register_token(env) + user = env.get("user").as(User) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + case env.request.headers["Content-Type"]? + when "application/x-www-form-urlencoded" + scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } + callback_url = env.params.body["callbackUrl"]? + expire = env.params.body["expire"]?.try &.to_i? + when "application/json" + scopes = env.params.json["scopes"].as(Array).map { |v| v.as_s } + callback_url = env.params.json["callbackUrl"]?.try &.as(String) + expire = env.params.json["expire"]?.try &.as(Int64) + else + return error_json(400, "Invalid or missing header 'Content-Type'") + end + + if callback_url && callback_url.empty? + callback_url = nil + end + + if callback_url + callback_url = URI.parse(callback_url) + end + + if sid = env.get?("sid").try &.as(String) + env.response.content_type = "text/html" + + csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true) + return templated "authorize_token" + else + env.response.content_type = "application/json" + + superset_scopes = env.get("scopes").as(Array(String)) + + authorized_scopes = [] of String + scopes.each do |scope| + if scopes_include_scope(superset_scopes, scope) + authorized_scopes << scope + end + end + + access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB) + + if callback_url + access_token = URI.encode_www_form(access_token) + + if query = callback_url.query + query = HTTP::Params.parse(query.not_nil!) + else + query = HTTP::Params.new + end + + query["token"] = access_token + callback_url.query = query.to_s + + env.redirect callback_url.to_s + else + access_token + end + end + end + + def self.unregister_token(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + env.response.content_type = "application/json" + user = env.get("user").as(User) + scopes = env.get("scopes").as(Array(String)) + + session = env.params.json["session"]?.try &.as(String) + session ||= env.get("session").as(String) + + # Allow tokens to revoke other tokens with correct scope + if session == env.get("session").as(String) + PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) + elsif scopes_include_scope(scopes, "GET:tokens") + PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) + else + return error_json(400, "Cannot revoke session #{session}") + end + + env.response.status_code = 204 + end +end diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index c7c32ca9..afb61fc1 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -10,4 +10,127 @@ module Invidious::Routes::APIv1::Misc Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json end + + # APIv1 currently uses the same logic for both + # user playlists and Invidious playlists. This means that we can't + # reasonably split them yet. This should be addressed in APIv2 + def self.get_playlist(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + plid = env.params.url["plid"] + + offset = env.params.query["index"]?.try &.to_i? + offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 } + offset ||= 0 + + continuation = env.params.query["continuation"]? + + format = env.params.query["format"]? + format ||= "json" + + if plid.starts_with? "RD" + return env.redirect "/api/v1/mixes/#{plid}" + end + + begin + playlist = get_playlist(PG_DB, plid, locale) + rescue ex : InfoException + return error_json(404, ex) + rescue ex + return error_json(404, "Playlist does not exist.") + end + + user = env.get?("user").try &.as(User) + if !playlist || playlist.privacy.private? && playlist.author != user.try &.email + return error_json(404, "Playlist does not exist.") + end + + response = playlist.to_json(offset, locale, continuation: continuation) + + if format == "html" + response = JSON.parse(response) + playlist_html = template_playlist(response) + index, next_video = response["videos"].as_a.skip(1).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} + + response = { + "playlistHtml" => playlist_html, + "index" => index, + "nextVideo" => next_video, + }.to_json + end + + response + end + + def self.mixes(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.content_type = "application/json" + + rdid = env.params.url["rdid"] + + continuation = env.params.query["continuation"]? + continuation ||= rdid.lchop("RD")[0, 11] + + format = env.params.query["format"]? + format ||= "json" + + begin + mix = fetch_mix(rdid, continuation, locale: locale) + + if !rdid.ends_with? continuation + mix = fetch_mix(rdid, mix.videos[1].id) + index = mix.videos.index(mix.videos.select { |video| video.id == continuation }[0]?) + end + + mix.videos = mix.videos[index..-1] + rescue ex + return error_json(500, ex) + end + + response = JSON.build do |json| + json.object do + json.field "title", mix.title + json.field "mixId", mix.id + + json.field "videos" do + json.array do + mix.videos.each do |video| + json.object do + json.field "title", video.title + json.field "videoId", video.id + json.field "author", video.author + + json.field "authorId", video.ucid + json.field "authorUrl", "/channel/#{video.ucid}" + + json.field "videoThumbnails" do + json.array do + generate_thumbnails(json, video.id) + end + end + + json.field "index", video.index + json.field "lengthSeconds", video.length_seconds + end + end + end + end + end + end + + if format == "html" + response = JSON.parse(response) + playlist_html = template_mix(response) + next_video = response["videos"].as_a.select { |video| !video["author"].as_s.empty? }[0]?.try &.["videoId"] + + response = { + "playlistHtml" => playlist_html, + "nextVideo" => next_video, + }.to_json + end + + response + end end diff --git a/src/invidious/routes/api/v1/routes.cr b/src/invidious/routes/api/v1/routes.cr index 4f06bdb4..9e3c03be 100644 --- a/src/invidious/routes/api/v1/routes.cr +++ b/src/invidious/routes/api/v1/routes.cr @@ -1,8 +1,6 @@ # There is far too many API routes to define in invidious.cr # so we'll just do it here instead with a macro. macro define_v1_api_routes(base_url = "/api/v1") - Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1::Misc, :stats - # Videos Invidious::Routing.get "#{{{base_url}}}/videos/:id", Invidious::Routes::APIv1::Videos, :videos Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::APIv1::Videos, :storyboards @@ -32,4 +30,38 @@ macro define_v1_api_routes(base_url = "/api/v1") # Search Invidious::Routing.get "#{{{base_url}}}/search", Invidious::Routes::APIv1::Search, :search Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::APIv1::Search, :search_suggestions + + # Authenticated + # Invidious::Routing.get "#{{{base_url}}}/auth/notifications", Invidious::Routes::APIv1::Authenticated, :notifications + # Invidious::Routing.post "#{{{base_url}}}/auth/notifications", Invidious::Routes::APIv1::Authenticated, :notifications + + Invidious::Routing.get "#{{{base_url}}}/auth/preferences", Invidious::Routes::APIv1::Authenticated, :get_preferences + Invidious::Routing.post "#{{{base_url}}}/auth/preferences", Invidious::Routes::APIv1::Authenticated, :set_preferences + + Invidious::Routing.get "#{{{base_url}}}/auth/feed", Invidious::Routes::APIv1::Authenticated, :feed + + Invidious::Routing.get "#{{{base_url}}}/auth/subscriptions", Invidious::Routes::APIv1::Authenticated, :get_subscriptions + Invidious::Routing.post "#{{{base_url}}}/auth/subscriptions/:ucid", Invidious::Routes::APIv1::Authenticated, :subscribe_channel + Invidious::Routing.delete "#{{{base_url}}}/auth/subscriptions/:ucid", Invidious::Routes::APIv1::Authenticated, :unsubscribe_channel + + + Invidious::Routing.get "#{{{base_url}}}/auth/playlists", Invidious::Routes::APIv1::Authenticated, :list_playlists + Invidious::Routing.post "#{{{base_url}}}/auth/playlists", Invidious::Routes::APIv1::Authenticated, :create_playlist + Invidious::Routing.patch "#{{{base_url}}}/auth/playlists/:ucid", Invidious::Routes::APIv1::Authenticated, :update_playlist_attribute + Invidious::Routing.delete "#{{{base_url}}}/auth/playlists/:ucid", Invidious::Routes::APIv1::Authenticated, :delete_playlist + + + Invidious::Routing.post "#{{{base_url}}}/auth/playlists/:ucid/videos", Invidious::Routes::APIv1::Authenticated, :insert_video_into_playlist + Invidious::Routing.delete "#{{{base_url}}}/auth/playlists/:ucid/videos/:index", Invidious::Routes::APIv1::Authenticated, :delete_video_in_playlist + + Invidious::Routing.get "#{{{base_url}}}/auth/tokens", Invidious::Routes::APIv1::Authenticated, :get_tokens + Invidious::Routing.post "#{{{base_url}}}/auth/tokens/register", Invidious::Routes::APIv1::Authenticated, :register_token + Invidious::Routing.post "#{{{base_url}}}/auth/tokens/unregister", Invidious::Routes::APIv1::Authenticated, :unregister_token + + # Misc + Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1::Misc, :stats + Invidious::Routing.get "#{{{base_url}}}/playlists/:plid", Invidious::Routes::APIv1::Misc, :get_playlist + Invidious::Routing.get "#{{{base_url}}}/auth/playlists/:plid", Invidious::Routes::APIv1::Misc, :get_playlist + Invidious::Routing.get "#{{{base_url}}}//mixes/:rdid", Invidious::Routes::APIv1::Misc, :mixes + end -- cgit v1.2.3 From 25362f16a0d0bcb8c2116b3e68750708486a43f5 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Sun, 15 Aug 2021 01:38:30 -0700 Subject: Readd paid attribute for videos (#2330) --- src/invidious/videos.cr | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 6a53b8ca..6a9c328e 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -301,6 +301,7 @@ struct Video json.field "likeCount", self.likes json.field "dislikeCount", self.dislikes + json.field "paid", self.paid json.field "premium", self.premium json.field "isFamilyFriendly", self.is_family_friendly json.field "allowedRegions", self.allowed_regions @@ -688,6 +689,12 @@ struct Video items end + def paid + reason = info["playabilityStatus"]?.try &.["reason"]? + paid = reason == "This video requires payment to watch." ? true : false + paid + end + def premium keywords.includes? "YouTube Red" end -- cgit v1.2.3 From b5d2eb5c708174f728f4961ee665166979e640dc Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Mon, 16 Aug 2021 19:41:16 +0200 Subject: fetch with innertube api when video is unavailable (#2329) + rename some client type to better names + fix thirdParty hack --- src/invidious/helpers/youtube_api.cr | 25 ++++++--------- src/invidious/videos.cr | 60 ++++++++---------------------------- 2 files changed, 22 insertions(+), 63 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr index 4ed707f6..b3815f6a 100644 --- a/src/invidious/helpers/youtube_api.cr +++ b/src/invidious/helpers/youtube_api.cr @@ -8,12 +8,12 @@ module YoutubeAPI # Enumerate used to select one of the clients supported by the API enum ClientType Web - WebEmbed + WebEmbeddedPlayer WebMobile - WebAgeBypass + WebScreenEmbed Android - AndroidEmbed - AndroidAgeBypass + AndroidEmbeddedPlayer + AndroidScreenEmbed end # List of hard-coded values used by the different clients @@ -24,7 +24,7 @@ module YoutubeAPI api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "WATCH_FULL_SCREEN", }, - ClientType::WebEmbed => { + ClientType::WebEmbeddedPlayer => { name: "WEB_EMBEDDED_PLAYER", # 56 version: "1.20210721.1.0", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", @@ -36,7 +36,7 @@ module YoutubeAPI api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "", # None }, - ClientType::WebAgeBypass => { + ClientType::WebScreenEmbed => { name: "WEB", version: "2.20210721.00.00", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", @@ -48,13 +48,13 @@ module YoutubeAPI api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", screen: "", # ?? }, - ClientType::AndroidEmbed => { + ClientType::AndroidEmbeddedPlayer => { name: "ANDROID_EMBEDDED_PLAYER", # 55 version: "16.20", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "", # None? }, - ClientType::AndroidAgeBypass => { + ClientType::AndroidScreenEmbed => { name: "ANDROID", # 3 version: "16.20", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", @@ -156,9 +156,6 @@ module YoutubeAPI "gl" => client_config.region || "US", # Can't be empty! "clientName" => client_config.name, "clientVersion" => client_config.version, - "thirdParty" => { - "embedUrl" => "", # Placeholder - }, }, } @@ -167,14 +164,10 @@ module YoutubeAPI client_context["client"]["clientScreen"] = client_config.screen end - # Replacing/removing the placeholder is easier than trying to - # merge two different Hash structures. if client_config.screen == "EMBED" - client_context["client"]["thirdParty"] = { + client_context["thirdParty"] = { "embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ", } - else - client_context["client"].delete("thirdParty") end return client_context diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 6a9c328e..27d85b92 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -819,10 +819,14 @@ def parse_related(r : JSON::Any) : JSON::Any? JSON::Any.new(rv) end -def extract_video_info(video_id : String, proxy_region : String? = nil) +def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) params = {} of String => JSON::Any client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) + if context_screen == "embed" + client_config.client_type = YoutubeAPI::ClientType::WebScreenEmbed + end + player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) if player_response["playabilityStatus"]?.try &.["status"]?.try &.as_s != "OK" @@ -844,7 +848,11 @@ def extract_video_info(video_id : String, proxy_region : String? = nil) # maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs: # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 if !params["reason"]? - client_config.client_type = YoutubeAPI::ClientType::Android + if context_screen == "embed" + client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed + else + client_config.client_type = YoutubeAPI::ClientType::Android + end stream_data = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) params["streamingData"] = stream_data["streamingData"]? || JSON::Any.new("") end @@ -972,52 +980,10 @@ def fetch_video(id, region) end end - # Try to pull streams from embed URL + # Try to fetch video info using an embedded client if info["reason"]? - required_parameters = { - "video_id" => id, - "eurl" => "https://youtube.googleapis.com/v/#{id}", - "html5" => "1", - "gl" => "US", - "hl" => "en", - } - if info["reason"].as_s.includes?("inappropriate") - # The html5, c and cver parameters are required in order to extract age-restricted videos - # See https://github.com/yt-dlp/yt-dlp/commit/4e6767b5f2e2523ebd3dd1240584ead53e8c8905 - required_parameters.merge!({ - "c" => "TVHTML5", - "cver" => "6.20180913", - }) - - # In order to actually extract video info without error, the `x-youtube-client-version` - # has to be set to the same version as `cver` above. - additional_headers = HTTP::Headers{"x-youtube-client-version" => "6.20180913"} - else - embed_page = YT_POOL.client &.get("/embed/#{id}").body - sts = embed_page.match(/"sts"\s*:\s*(?\d+)/).try &.["sts"]? || "" - required_parameters["sts"] = sts - additional_headers = HTTP::Headers{} of String => String - end - - embed_info = HTTP::Params.parse(YT_POOL.client &.get("/get_video_info?#{URI::Params.encode(required_parameters)}", - headers: additional_headers).body) - - if embed_info["player_response"]? - player_response = JSON.parse(embed_info["player_response"]) - {"captions", "microformat", "playabilityStatus", "streamingData", "videoDetails", "storyboards"}.each do |f| - info[f] = player_response[f] if player_response[f]? - end - end - - initial_data = JSON.parse(embed_info["watch_next_response"]) if embed_info["watch_next_response"]? - - info["relatedVideos"] = initial_data.try &.["playerOverlays"]?.try &.["playerOverlayRenderer"]? - .try &.["endScreen"]?.try &.["watchNextEndScreenRenderer"]?.try &.["results"]?.try &.as_a.compact_map { |r| - parse_related r - }.try { |a| JSON::Any.new(a) } || embed_info["rvs"]?.try &.split(",").map { |r| - r = HTTP::Params.parse(r).to_h - JSON::Any.new(Hash.zip(r.keys, r.values.map { |v| JSON::Any.new(v) })) - }.try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) + embed_info = extract_video_info(video_id: id, context_screen: "embed") + info = embed_info if !embed_info["reason"]? end raise InfoException.new(info["reason"]?.try &.as_s || "") if !info["videoDetails"]? -- cgit v1.2.3 From 66b45a8fe2dde8eafa88db8b3077dad7305c068e Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 23 Aug 2021 16:28:30 -0700 Subject: Bountiful changes - Use haltf in more locations - Fix wrong URL params - Rename API modules - Remove API routing file and move everything to general iv routing file --- src/invidious/routes/api/manifest.cr | 15 +---- src/invidious/routes/api/v1/authenticated.cr | 5 +- src/invidious/routes/api/v1/channels.cr | 15 ++++- src/invidious/routes/api/v1/feeds.cr | 5 +- src/invidious/routes/api/v1/misc.cr | 2 +- src/invidious/routes/api/v1/routes.cr | 67 --------------------- src/invidious/routes/api/v1/search.cr | 2 +- src/invidious/routes/api/v1/videos.cr | 31 ++++------ src/invidious/routes/video_playback.cr | 10 ---- src/invidious/routing.cr | 89 ++++++++++++++++++++++++++++ 10 files changed, 122 insertions(+), 119 deletions(-) delete mode 100644 src/invidious/routes/api/v1/routes.cr (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index 31e1a123..93bee55c 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIManifest +module Invidious::Routes::API::Manifest # /api/manifest/dash/id/:id def self.get_dash_video_id(env) env.response.headers.add("Access-Control-Allow-Origin", "*") @@ -222,16 +222,3 @@ module Invidious::Routes::APIManifest manifest end end - -macro define_api_manifest_routes - Invidious::Routing.get "/api/manifest/dash/id/:id", Invidious::Routes::APIManifest, :get_dash_video_id - - Invidious::Routing.get "/api/manifest/dash/id/videoplayback", Invidious::Routes::APIManifest, :get_dash_video_playback - Invidious::Routing.get "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::APIManifest, :get_dash_video_playback_greedy - - Invidious::Routing.options "/api/manifest/dash/id/videoplayback", Invidious::Routes::APIManifest, :options_dash_video_playback - Invidious::Routing.options "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::APIManifest, :options_dash_video_playback - - Invidious::Routing.get "/api/manifest/hls_playlist/*", Invidious::Routes::APIManifest, :get_hls_playlist - Invidious::Routing.get "/api/manifest/hls_variant/*", Invidious::Routes::APIManifest, :get_hls_variant -end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 4201f26d..b4e9e9c8 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -1,4 +1,7 @@ -module Invidious::Routes::APIv1::Authenticated +module Invidious::Routes::API::V1::Authenticated + # The notification APIs cannot be extracted yet! + # They require the *local* notifications constant defined in invidious.cr + # # def self.notifications(env) # env.response.content_type = "text/event-stream" diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 3401232b..5caa656d 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIv1::Channels +module Invidious::Routes::API::V1::Channels def self.home(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? @@ -241,7 +241,7 @@ module Invidious::Routes::APIv1::Channels end end - def self.channel_search(env) + def self.search(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? env.response.content_type = "application/json" @@ -263,4 +263,15 @@ module Invidious::Routes::APIv1::Channels end end end + + # 301 redirect from /api/v1/channels/comments/:ucid + # and /api/v1/channels/:ucid/comments to new /api/v1/channels/:ucid/community and + # corresponding equivalent URL structure of the other one. + def self.channel_comments_redirect(env) + env.response.content_type = "application/json" + ucid = env.params.url["ucid"] + + env.response.headers["Location"] = "/api/v1/channels/#{ucid}/community?#{env.params.query}" + haltf env, status_code: 301 + end end diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr index 0107b71d..bb8f661b 100644 --- a/src/invidious/routes/api/v1/feeds.cr +++ b/src/invidious/routes/api/v1/feeds.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIv1::Feeds +module Invidious::Routes::API::V1::Feeds def self.trending(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? @@ -31,8 +31,7 @@ module Invidious::Routes::APIv1::Feeds if !CONFIG.popular_enabled error_message = {"error" => "Administrator has disabled this endpoint."}.to_json - env.response.status_code = 400 - return error_message + haltf env, 400, error_message end JSON.build do |json| diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index afb61fc1..cf95bd9b 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIv1::Misc +module Invidious::Routes::API::V1::Misc # Stats API endpoint for Invidious def self.stats(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? diff --git a/src/invidious/routes/api/v1/routes.cr b/src/invidious/routes/api/v1/routes.cr deleted file mode 100644 index 9e3c03be..00000000 --- a/src/invidious/routes/api/v1/routes.cr +++ /dev/null @@ -1,67 +0,0 @@ -# There is far too many API routes to define in invidious.cr -# so we'll just do it here instead with a macro. -macro define_v1_api_routes(base_url = "/api/v1") - # Videos - Invidious::Routing.get "#{{{base_url}}}/videos/:id", Invidious::Routes::APIv1::Videos, :videos - Invidious::Routing.get "#{{{base_url}}}/storyboards/:id", Invidious::Routes::APIv1::Videos, :storyboards - Invidious::Routing.get "#{{{base_url}}}/captions/:id", Invidious::Routes::APIv1::Videos, :captions - Invidious::Routing.get "#{{{base_url}}}/annotations/:id", Invidious::Routes::APIv1::Videos, :annotations - Invidious::Routing.get "#{{{base_url}}}/comments/:id", Invidious::Routes::APIv1::Videos, :comments - - # Feeds - Invidious::Routing.get "#{{{base_url}}}/trending", Invidious::Routes::APIv1::Feeds, :trending - Invidious::Routing.get "#{{{base_url}}}/popular", Invidious::Routes::APIv1::Feeds, :popular - - # Channels - Invidious::Routing.get "#{{{base_url}}}/channels/:ucid", Invidious::Routes::APIv1::Channels, :home - {% for route in { - {"home", "home"}, - {"videos", "videos"}, - {"latest", "latest"}, - {"playlists", "playlists"}, - {"comments", "community"}, # Why is the route for the community API `comments`?, - {"search", "channel_search"}, - } %} - - Invidious::Routing.get "#{{{base_url}}}/channels/#{{{route[0]}}}/:ucid", Invidious::Routes::APIv1::Channels, :{{route[1]}} - Invidious::Routing.get "#{{{base_url}}}/channels/:ucid/#{{{route[0]}}}", Invidious::Routes::APIv1::Channels, :{{route[1]}} - {% end %} - - # Search - Invidious::Routing.get "#{{{base_url}}}/search", Invidious::Routes::APIv1::Search, :search - Invidious::Routing.get "#{{{base_url}}}/search/suggestions/:id", Invidious::Routes::APIv1::Search, :search_suggestions - - # Authenticated - # Invidious::Routing.get "#{{{base_url}}}/auth/notifications", Invidious::Routes::APIv1::Authenticated, :notifications - # Invidious::Routing.post "#{{{base_url}}}/auth/notifications", Invidious::Routes::APIv1::Authenticated, :notifications - - Invidious::Routing.get "#{{{base_url}}}/auth/preferences", Invidious::Routes::APIv1::Authenticated, :get_preferences - Invidious::Routing.post "#{{{base_url}}}/auth/preferences", Invidious::Routes::APIv1::Authenticated, :set_preferences - - Invidious::Routing.get "#{{{base_url}}}/auth/feed", Invidious::Routes::APIv1::Authenticated, :feed - - Invidious::Routing.get "#{{{base_url}}}/auth/subscriptions", Invidious::Routes::APIv1::Authenticated, :get_subscriptions - Invidious::Routing.post "#{{{base_url}}}/auth/subscriptions/:ucid", Invidious::Routes::APIv1::Authenticated, :subscribe_channel - Invidious::Routing.delete "#{{{base_url}}}/auth/subscriptions/:ucid", Invidious::Routes::APIv1::Authenticated, :unsubscribe_channel - - - Invidious::Routing.get "#{{{base_url}}}/auth/playlists", Invidious::Routes::APIv1::Authenticated, :list_playlists - Invidious::Routing.post "#{{{base_url}}}/auth/playlists", Invidious::Routes::APIv1::Authenticated, :create_playlist - Invidious::Routing.patch "#{{{base_url}}}/auth/playlists/:ucid", Invidious::Routes::APIv1::Authenticated, :update_playlist_attribute - Invidious::Routing.delete "#{{{base_url}}}/auth/playlists/:ucid", Invidious::Routes::APIv1::Authenticated, :delete_playlist - - - Invidious::Routing.post "#{{{base_url}}}/auth/playlists/:ucid/videos", Invidious::Routes::APIv1::Authenticated, :insert_video_into_playlist - Invidious::Routing.delete "#{{{base_url}}}/auth/playlists/:ucid/videos/:index", Invidious::Routes::APIv1::Authenticated, :delete_video_in_playlist - - Invidious::Routing.get "#{{{base_url}}}/auth/tokens", Invidious::Routes::APIv1::Authenticated, :get_tokens - Invidious::Routing.post "#{{{base_url}}}/auth/tokens/register", Invidious::Routes::APIv1::Authenticated, :register_token - Invidious::Routing.post "#{{{base_url}}}/auth/tokens/unregister", Invidious::Routes::APIv1::Authenticated, :unregister_token - - # Misc - Invidious::Routing.get "#{{{base_url}}}/stats", Invidious::Routes::APIv1::Misc, :stats - Invidious::Routing.get "#{{{base_url}}}/playlists/:plid", Invidious::Routes::APIv1::Misc, :get_playlist - Invidious::Routing.get "#{{{base_url}}}/auth/playlists/:plid", Invidious::Routes::APIv1::Misc, :get_playlist - Invidious::Routing.get "#{{{base_url}}}//mixes/:rdid", Invidious::Routes::APIv1::Misc, :mixes - -end diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index e4d5809f..f3a6fa06 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIv1::Search +module Invidious::Routes::API::V1::Search def self.search(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? region = env.params.query["region"]? diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 0eb2fca3..575e6fdf 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -1,4 +1,4 @@ -module Invidious::Routes::APIv1::Videos +module Invidious::Routes::API::V1::Videos def self.videos(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? @@ -41,8 +41,7 @@ module Invidious::Routes::APIv1::Videos env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex - env.response.status_code = 500 - return + haltf env, 500 end captions = video.captions @@ -80,8 +79,7 @@ module Invidious::Routes::APIv1::Videos end if caption.empty? - env.response.status_code = 404 - return + haltf env, 404 else caption = caption[0] end @@ -164,8 +162,7 @@ module Invidious::Routes::APIv1::Videos env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex - env.response.status_code = 500 - return + haltf env, 500 end storyboards = video.storyboards @@ -189,8 +186,7 @@ module Invidious::Routes::APIv1::Videos storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" } if storyboard.empty? - env.response.status_code = 404 - return + haltf env, 404 else storyboard = storyboard[0] end @@ -236,8 +232,7 @@ module Invidious::Routes::APIv1::Videos source ||= "archive" if !id.match(/[a-zA-Z0-9_-]{11}/) - env.response.status_code = 400 - return + haltf env, 400 end annotations = "" @@ -267,13 +262,11 @@ module Invidious::Routes::APIv1::Videos response = make_client(URI.parse(location.headers["Location"]), &.get(location.headers["Location"])) if response.body.empty? - env.response.status_code = 404 - return + haltf env, 404 end if response.status_code != 200 - env.response.status_code = response.status_code - return + haltf env, response.status_code end annotations = response.body @@ -284,8 +277,7 @@ module Invidious::Routes::APIv1::Videos response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") if response.status_code != 200 - env.response.status_code = response.status_code - return + haltf env, response.status_code end annotations = response.body @@ -293,7 +285,7 @@ module Invidious::Routes::APIv1::Videos etag = sha256(annotations)[0, 16] if env.request.headers["If-None-Match"]?.try &.== etag - env.response.status_code = 304 + haltf env, 304 else env.response.headers["ETag"] = etag annotations @@ -349,8 +341,7 @@ module Invidious::Routes::APIv1::Videos end if !reddit_thread || !comments - env.response.status_code = 404 - return + haltf env, 404 end if format == "json" diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 0fe2853d..acbf62b4 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -278,13 +278,3 @@ module Invidious::Routes::VideoPlayback return env.redirect url end end - -macro define_video_playback_routes - Invidious::Routing.get "/videoplayback", Invidious::Routes::VideoPlayback, :get_video_playback - Invidious::Routing.get "/videoplayback/*", Invidious::Routes::VideoPlayback, :get_video_playback_greedy - - Invidious::Routing.options "/videoplayback", Invidious::Routes::VideoPlayback, :options_video_playback - Invidious::Routing.options "/videoplayback/*", Invidious::Routes::VideoPlayback, :options_video_playback - - Invidious::Routing.get "/latest_version", Invidious::Routes::VideoPlayback, :latest_version -end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 1fd3477d..62a51399 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -9,3 +9,92 @@ module Invidious::Routing {% end %} end + +macro define_v1_api_routes + {{namespace = Invidious::Routes::API::V1}} + # Videos + Invidious::Routing.get "/api/v1/videos/:id", {{namespace}}::Videos, :videos + Invidious::Routing.get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards + Invidious::Routing.get "/api/v1/captions/:id", {{namespace}}::Videos, :captions + Invidious::Routing.get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations + Invidious::Routing.get "/api/v1/comments/:id", {{namespace}}::Videos, :comments + + # Feeds + Invidious::Routing.get "/api/v1/trending", {{namespace}}::Feeds, :trending + Invidious::Routing.get "/api/v1/popular", {{namespace}}::Feeds, :popular + + # Channels + Invidious::Routing.get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home + {% for route in {"videos", "latest", "playlists", "community", "search"} %} + Invidious::Routing.get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} + Invidious::Routing.get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} + {% end %} + + # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community + Invidious::Routing.get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect + Invidious::Routing.get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect + + + # Search + Invidious::Routing.get "/api/v1/search", {{namespace}}::Search, :search + Invidious::Routing.get "/api/v1/search/suggestions/:id", {{namespace}}::Search, :search_suggestions + + # Authenticated + + # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr + # + # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + + Invidious::Routing.get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences + Invidious::Routing.post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences + + Invidious::Routing.get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed + + Invidious::Routing.get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions + Invidious::Routing.post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel + Invidious::Routing.delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel + + + Invidious::Routing.get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists + Invidious::Routing.post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist + Invidious::Routing.patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute + Invidious::Routing.delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist + + + Invidious::Routing.post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist + Invidious::Routing.delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist + + Invidious::Routing.get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens + Invidious::Routing.post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token + Invidious::Routing.post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token + + # Misc + Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats + Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist + Invidious::Routing.get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist + Invidious::Routing.get "/api/v1//mixes/:rdid", {{namespace}}::Misc, :mixes +end + +macro define_api_manifest_routes + Invidious::Routing.get "/api/manifest/dash/id/:id", Invidious::Routes::API::Manifest, :get_dash_video_id + + Invidious::Routing.get "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :get_dash_video_playback + Invidious::Routing.get "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :get_dash_video_playback_greedy + + Invidious::Routing.options "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :options_dash_video_playback + Invidious::Routing.options "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :options_dash_video_playback + + Invidious::Routing.get "/api/manifest/hls_playlist/*", Invidious::Routes::API::Manifest, :get_hls_playlist + Invidious::Routing.get "/api/manifest/hls_variant/*", Invidious::Routes::API::Manifest, :get_hls_variant +end + +macro define_video_playback_routes + Invidious::Routing.get "/videoplayback", Invidious::Routes::VideoPlayback, :get_video_playback + Invidious::Routing.get "/videoplayback/*", Invidious::Routes::VideoPlayback, :get_video_playback_greedy + + Invidious::Routing.options "/videoplayback", Invidious::Routes::VideoPlayback, :options_video_playback + Invidious::Routing.options "/videoplayback/*", Invidious::Routes::VideoPlayback, :options_video_playback + + Invidious::Routing.get "/latest_version", Invidious::Routes::VideoPlayback, :latest_version +end -- cgit v1.2.3 From 52688106e4cf36d84f530baf062f60d77ba2ab20 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 23 Aug 2021 16:38:29 -0700 Subject: Fix /api/v1/search/suggestions route link --- src/invidious/routing.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 62a51399..e0cddeb5 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -37,7 +37,7 @@ macro define_v1_api_routes # Search Invidious::Routing.get "/api/v1/search", {{namespace}}::Search, :search - Invidious::Routing.get "/api/v1/search/suggestions/:id", {{namespace}}::Search, :search_suggestions + Invidious::Routing.get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions # Authenticated -- cgit v1.2.3 From d984a898d49f8f15796c5ac18c288bffdd387e43 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 23 Aug 2021 17:05:57 -0700 Subject: Remove usage of haltf in /api/v1/channels/:ucid/comments --- src/invidious/routes/api/v1/channels.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 5caa656d..da39661c 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -272,6 +272,7 @@ module Invidious::Routes::API::V1::Channels ucid = env.params.url["ucid"] env.response.headers["Location"] = "/api/v1/channels/#{ucid}/community?#{env.params.query}" - haltf env, status_code: 301 + env.response.status_code = 301 + return end end -- cgit v1.2.3 From fceb8093f17f3fce8462e619d3fddc7399672771 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Tue, 24 Aug 2021 19:59:27 +0000 Subject: Use `athena-negotiation` to detect language through Accept-Language header (#2324) Detect language through Accept-Language header --- shard.lock | 4 ++++ shard.yml | 3 +++ src/invidious.cr | 17 ++++++++++++++--- src/invidious/routes/login.cr | 7 +++++++ 4 files changed, 28 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/shard.lock b/shard.lock index 35d1aefd..bfb54ee1 100644 --- a/shard.lock +++ b/shard.lock @@ -1,5 +1,9 @@ version: 2.0 shards: + athena-negotiation: + git: https://github.com/athena-framework/negotiation.git + version: 0.1.1 + db: git: https://github.com/crystal-lang/crystal-db.git version: 0.10.1 diff --git a/shard.yml b/shard.yml index 2df4909c..3292e505 100644 --- a/shard.yml +++ b/shard.yml @@ -25,6 +25,9 @@ dependencies: lsquic: github: iv-org/lsquic.cr version: ~> 2.18.1-2 + athena-negotiation: + github: athena-framework/negotiation + version: ~> 0.1.1 crystal: ">= 1.0.0, < 2.0.0" diff --git a/src/invidious.cr b/src/invidious.cr index c940dadf..4d739340 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -17,6 +17,7 @@ require "digest/md5" require "file_utils" require "kemal" +require "athena-negotiation" require "openssl/hmac" require "option_parser" require "pg" @@ -166,10 +167,20 @@ def popular_videos end before_all do |env| - preferences = begin - Preferences.from_json(URI.decode_www_form(env.request.cookies["PREFS"]?.try &.value || "{}")) + preferences = Preferences.from_json("{}") + + begin + if prefs_cookie = env.request.cookies["PREFS"]? + preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value)) + else + if language_header = env.request.headers["Accept-Language"]? + if language = ANG.language_negotiator.best(language_header, LOCALES.keys) + preferences.locale = language.header + end + end + end rescue - Preferences.from_json("{}") + preferences = Preferences.from_json("{}") end env.set "preferences", preferences diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 21d3fafd..f052d3f4 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -434,6 +434,13 @@ module Invidious::Routes::Login sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) user, sid = create_user(sid, email, password) + + if language_header = env.request.headers["Accept-Language"]? + if language = ANG.language_negotiator.best(language_header, LOCALES.keys) + user.preferences.locale = language.header + end + end + user_array = user.to_a user_array[4] = user_array[4].to_json # User preferences -- cgit v1.2.3 From a279d6f4331effbf7327a2e3c90a937223656c55 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Thu, 26 Aug 2021 21:02:26 +0000 Subject: Fix livestream parsing URLs (#2356) --- src/invidious.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 4d739340..402e8974 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -3150,7 +3150,7 @@ get "/api/manifest/hls_playlist/*" do |env| manifest = response.body if local - manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match| + manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match| path = URI.parse(match).path path = path.lchop("/videoplayback/") -- cgit v1.2.3 From 5005212bec654a0adcde5c9cb511e12c0926a3e6 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Mon, 30 Aug 2021 14:58:24 +0000 Subject: Extract feed routes (#2269) * Extract feed routes from invidious.cr * Removes the deprecated route for /feed/top * Deprecate /view_all_playlist & use /feed/playlists * Move feed views into their own directory * Add haltf method to halt current route context * Change status_code + return blocks to use haltf * Set appropriate response headers for RSS routes --- src/invidious.cr | 438 ++-------------------------- src/invidious/helpers/macros.cr | 9 + src/invidious/routes/feeds.cr | 431 +++++++++++++++++++++++++++ src/invidious/routes/misc.cr | 2 +- src/invidious/routes/playlists.cr | 27 +- src/invidious/views/feeds/history.ecr | 71 +++++ src/invidious/views/feeds/playlists.ecr | 34 +++ src/invidious/views/feeds/popular.ecr | 18 ++ src/invidious/views/feeds/subscriptions.ecr | 77 +++++ src/invidious/views/feeds/trending.ecr | 47 +++ src/invidious/views/history.ecr | 71 ----- src/invidious/views/playlist.ecr | 2 +- src/invidious/views/popular.ecr | 18 -- src/invidious/views/preferences.ecr | 2 +- src/invidious/views/subscriptions.ecr | 77 ----- src/invidious/views/trending.ecr | 47 --- src/invidious/views/view_all_playlists.ecr | 34 --- 17 files changed, 709 insertions(+), 696 deletions(-) create mode 100644 src/invidious/routes/feeds.cr create mode 100644 src/invidious/views/feeds/history.ecr create mode 100644 src/invidious/views/feeds/playlists.ecr create mode 100644 src/invidious/views/feeds/popular.ecr create mode 100644 src/invidious/views/feeds/subscriptions.ecr create mode 100644 src/invidious/views/feeds/trending.ecr delete mode 100644 src/invidious/views/history.ecr delete mode 100644 src/invidious/views/popular.ecr delete mode 100644 src/invidious/views/subscriptions.ecr delete mode 100644 src/invidious/views/trending.ecr delete mode 100644 src/invidious/views/view_all_playlists.ecr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 402e8974..f497a527 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -349,7 +349,6 @@ Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_red Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show -Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Playlists, :index Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe @@ -374,6 +373,24 @@ Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :sho Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme +# Feeds +Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect +Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists +Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular +Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending +Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions +Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history + +# RSS Feeds +Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel +Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private +Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist +Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos + +# Support push notifications via PubSubHubbub +Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get +Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post + # Users post "/watch_ajax" do |env| @@ -1190,425 +1207,6 @@ post "/token_ajax" do |env| end end -# Feeds - -get "/feed/playlists" do |env| - env.redirect "/view_all_playlists" -end - -get "/feed/top" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - message = translate(locale, "The Top feed has been removed from Invidious.") - templated "message" -end - -get "/feed/popular" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - if CONFIG.popular_enabled - templated "popular" - else - message = translate(locale, "The Popular feed has been disabled by the administrator.") - templated "message" - end -end - -get "/feed/trending" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - trending_type = env.params.query["type"]? - trending_type ||= "Default" - - region = env.params.query["region"]? - region ||= "US" - - begin - trending, plid = fetch_trending(trending_type, region, locale) - rescue ex - next error_template(500, ex) - end - - templated "trending" -end - -get "/feed/subscriptions" 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 referer - end - - user = user.as(User) - sid = sid.as(String) - token = user.token - - if user.preferences.unseen_only - env.set "show_watched", true - end - - # Refresh account - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - if !user.password - user, sid = get_user(sid, headers, PG_DB) - end - - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) - - # "updated" here is used for delivering new notifications, so if - # we know a user has looked at their feed e.g. in the past 10 minutes, - # they've already seen a video posted 20 minutes ago, and don't need - # to be notified. - PG_DB.exec("UPDATE users SET notifications = $1, updated = $2 WHERE email = $3", [] of String, Time.utc, - user.email) - user.notifications = [] of String - env.set "user", user - - templated "subscriptions" -end - -get "/feed/history" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - referer = get_referer(env) - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - if !user - next env.redirect referer - end - - user = user.as(User) - - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - if user.watched[(page - 1) * max_results]? - watched = user.watched.reverse[(page - 1) * max_results, max_results] - end - watched ||= [] of String - - templated "history" -end - -get "/feed/channel/:ucid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/atom+xml" - - ucid = env.params.url["ucid"] - - params = HTTP::Params.parse(env.params.query["params"]? || "") - - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - next env.redirect env.request.resource.gsub(ucid, ex.channel_id) - rescue ex - next error_atom(500, ex) - end - - response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") - rss = XML.parse_html(response.body) - - videos = rss.xpath_nodes("//feed/entry").map do |entry| - video_id = entry.xpath_node("videoid").not_nil!.content - title = entry.xpath_node("title").not_nil!.content - - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - - author = entry.xpath_node("author/name").not_nil!.content - ucid = entry.xpath_node("channelid").not_nil!.content - description_html = entry.xpath_node("group/description").not_nil!.to_s - views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64 - - SearchVideo.new({ - title: title, - id: video_id, - author: author, - ucid: ucid, - published: published, - views: views, - description_html: description_html, - length_seconds: 0, - live_now: false, - premium: false, - premiere_timestamp: nil, - }) - end - - 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 "yt:channel:#{channel.ucid}" } - xml.element("yt:channelId") { xml.text channel.ucid } - xml.element("icon") { xml.text channel.author_thumbnail } - xml.element("title") { xml.text channel.author } - xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") - - xml.element("author") do - xml.element("name") { xml.text channel.author } - xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } - end - - videos.each do |video| - video.to_xml(channel.auto_generated, params, xml) - end - end - end -end - -get "/feed/private" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/atom+xml" - - token = env.params.query["token"]? - - if !token - env.response.status_code = 403 - next - end - - user = PG_DB.query_one?("SELECT * FROM users WHERE token = $1", token.strip, as: User) - if !user - env.response.status_code = 403 - next - end - - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) - max_results ||= user.preferences.max_results - max_results ||= CONFIG.default_user_preferences.max_results - - 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) - - 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", "type": "text/html", rel: "alternate", href: "#{HOST_URL}/feed/subscriptions") - xml.element("link", "type": "application/atom+xml", rel: "self", - href: "#{HOST_URL}#{env.request.resource}") - xml.element("title") { xml.text translate(locale, "Invidious Private Feed for `x`", user.email) } - - (notifications + videos).each do |video| - video.to_xml(locale, params, xml) - end - end - end -end - -get "/feed/playlist/:plid" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - env.response.content_type = "application/atom+xml" - - plid = env.params.url["plid"] - - params = HTTP::Params.parse(env.params.query["params"]? || "") - 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(false, xml) - end - end - end - else - env.response.status_code = 404 - next - end - end - - response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") - document = XML.parse(response.body) - - document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node| - node.attributes.each do |attribute| - case attribute.name - when "url", "href" - request_target = URI.parse(node[attribute.name]).request_target - query_string_opt = request_target.starts_with?("/watch?v=") ? "&#{params}" : "" - node[attribute.name] = "#{HOST_URL}#{request_target}#{query_string_opt}" - else nil # Skip - end - end - end - - document = document.to_xml(options: XML::SaveOptions::NO_DECL) - - document.scan(/(?[^<]+)<\/uri>/).each do |match| - content = "#{HOST_URL}#{URI.parse(match["url"]).request_target}" - document = document.gsub(match[0], "#{content}") - end - - document -end - -get "/feeds/videos.xml" do |env| - if ucid = env.params.query["channel_id"]? - env.redirect "/feed/channel/#{ucid}" - elsif user = env.params.query["user"]? - env.redirect "/feed/channel/#{user}" - elsif plid = env.params.query["playlist_id"]? - env.redirect "/feed/playlist/#{plid}" - end -end - -# Support push notifications via PubSubHubbub - -get "/feed/webhook/:token" do |env| - verify_token = env.params.url["token"] - - mode = env.params.query["hub.mode"]? - topic = env.params.query["hub.topic"]? - challenge = env.params.query["hub.challenge"]? - - if !mode || !topic || !challenge - env.response.status_code = 400 - next - else - mode = mode.not_nil! - topic = topic.not_nil! - challenge = challenge.not_nil! - end - - case verify_token - when .starts_with? "v1" - _, time, nonce, signature = verify_token.split(":") - data = "#{time}:#{nonce}" - when .starts_with? "v2" - time, signature = verify_token.split(":") - data = "#{time}" - else - env.response.status_code = 400 - next - end - - # The hub will sometimes check if we're still subscribed after delivery errors, - # so we reply with a 200 as long as the request hasn't expired - if Time.utc.to_unix - time.to_i > 432000 - env.response.status_code = 400 - next - end - - if OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, data) != signature - env.response.status_code = 400 - next - end - - if ucid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["channel_id"]? - PG_DB.exec("UPDATE channels SET subscribed = $1 WHERE id = $2", Time.utc, ucid) - elsif plid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["playlist_id"]? - PG_DB.exec("UPDATE playlists SET subscribed = $1 WHERE id = $2", Time.utc, ucid) - else - env.response.status_code = 400 - next - end - - env.response.status_code = 200 - challenge -end - -post "/feed/webhook/:token" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - token = env.params.url["token"] - body = env.request.body.not_nil!.gets_to_end - signature = env.request.headers["X-Hub-Signature"].lchop("sha1=") - - if signature != OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, body) - LOGGER.error("/feed/webhook/#{token} : Invalid signature") - env.response.status_code = 200 - next - end - - spawn do - rss = XML.parse_html(body) - rss.xpath_nodes("//feed/entry").each do |entry| - id = entry.xpath_node("videoid").not_nil!.content - author = entry.xpath_node("author/name").not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - - video = get_video(id, PG_DB, force_refresh: true) - - # Deliver notifications to `/api/v1/auth/notifications` - payload = { - "topic" => video.ucid, - "videoId" => video.id, - "published" => published.to_unix, - }.to_json - PG_DB.exec("NOTIFY notifications, E'#{payload}'") - - video = ChannelVideo.new({ - id: id, - title: video.title, - published: published, - updated: updated, - ucid: video.ucid, - author: author, - length_seconds: video.length_seconds, - live_now: video.live_now, - premiere_timestamp: video.premiere_timestamp, - views: video.views, - }) - - was_insert = PG_DB.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, - updated = $4, ucid = $5, author = $6, length_seconds = $7, - live_now = $8, premiere_timestamp = $9, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) - - PG_DB.exec("UPDATE users SET notifications = array_append(notifications, $1), - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert - end - end - - env.response.status_code = 200 - next -end - # Channels {"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route| diff --git a/src/invidious/helpers/macros.cr b/src/invidious/helpers/macros.cr index 5d426a8b..75df1612 100644 --- a/src/invidious/helpers/macros.cr +++ b/src/invidious/helpers/macros.cr @@ -56,3 +56,12 @@ end macro rendered(filename) render "src/invidious/views/#{{{filename}}}.ecr" end + +# Similar to Kemals halt method but works in a +# method. +macro haltf(env, status_code = 200, response = "") + {{env}}.response.status_code = {{status_code}} + {{env}}.response.print {{response}} + {{env}}.response.close + return +end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr new file mode 100644 index 00000000..c88e96cf --- /dev/null +++ b/src/invidious/routes/feeds.cr @@ -0,0 +1,431 @@ +module Invidious::Routes::Feeds + def self.view_all_playlists_redirect(env) + env.redirect "/feed/playlists" + end + + def self.playlists(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + referer = get_referer(env) + + return env.redirect "/" if user.nil? + + user = user.as(User) + + items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + items_created.map! do |item| + item.author = "" + item + end + + items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + items_saved.map! do |item| + item.author = "" + item + end + + templated "feeds/playlists" + end + + def self.popular(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + if CONFIG.popular_enabled + templated "feeds/popular" + else + message = translate(locale, "The Popular feed has been disabled by the administrator.") + templated "message" + end + end + + def self.trending(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + trending_type = env.params.query["type"]? + trending_type ||= "Default" + + region = env.params.query["region"]? + region ||= "US" + + begin + trending, plid = fetch_trending(trending_type, region, locale) + rescue ex + return error_template(500, ex) + end + + templated "feeds/trending" + end + + def self.subscriptions(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = user.token + + if user.preferences.unseen_only + env.set "show_watched", true + end + + # Refresh account + headers = HTTP::Headers.new + headers["Cookie"] = env.request.headers["Cookie"] + + if !user.password + user, sid = get_user(sid, headers, PG_DB) + end + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + + # "updated" here is used for delivering new notifications, so if + # we know a user has looked at their feed e.g. in the past 10 minutes, + # they've already seen a video posted 20 minutes ago, and don't need + # to be notified. + PG_DB.exec("UPDATE users SET notifications = $1, updated = $2 WHERE email = $3", [] of String, Time.utc, + user.email) + user.notifications = [] of String + env.set "user", user + + templated "feeds/subscriptions" + end + + def self.history(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + user = env.get? "user" + referer = get_referer(env) + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + if !user + return env.redirect referer + end + + user = user.as(User) + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + if user.watched[(page - 1) * max_results]? + watched = user.watched.reverse[(page - 1) * max_results, max_results] + end + watched ||= [] of String + + templated "feeds/history" + end + + # RSS feeds + + def self.rss_channel(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.headers["Content-Type"] = "application/atom+xml" + env.response.content_type = "application/atom+xml" + + ucid = env.params.url["ucid"] + + params = HTTP::Params.parse(env.params.query["params"]? || "") + + begin + channel = get_about_info(ucid, locale) + rescue ex : ChannelRedirect + return env.redirect env.request.resource.gsub(ucid, ex.channel_id) + rescue ex + return error_atom(500, ex) + end + + response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") + rss = XML.parse_html(response.body) + + videos = rss.xpath_nodes("//feed/entry").map do |entry| + video_id = entry.xpath_node("videoid").not_nil!.content + title = entry.xpath_node("title").not_nil!.content + + published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) + + author = entry.xpath_node("author/name").not_nil!.content + ucid = entry.xpath_node("channelid").not_nil!.content + description_html = entry.xpath_node("group/description").not_nil!.to_s + views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64 + + SearchVideo.new({ + title: title, + id: video_id, + author: author, + ucid: ucid, + published: published, + views: views, + description_html: description_html, + length_seconds: 0, + live_now: false, + paid: false, + premium: false, + premiere_timestamp: nil, + }) + end + + 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 "yt:channel:#{channel.ucid}" } + xml.element("yt:channelId") { xml.text channel.ucid } + xml.element("icon") { xml.text channel.author_thumbnail } + xml.element("title") { xml.text channel.author } + xml.element("link", rel: "alternate", href: "#{HOST_URL}/channel/#{channel.ucid}") + + xml.element("author") do + xml.element("name") { xml.text channel.author } + xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } + end + + videos.each do |video| + video.to_xml(channel.auto_generated, params, xml) + end + end + end + end + + def self.rss_private(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.headers["Content-Type"] = "application/atom+xml" + env.response.content_type = "application/atom+xml" + + token = env.params.query["token"]? + + if !token + haltf env, status_code: 403 + end + + user = PG_DB.query_one?("SELECT * FROM users WHERE token = $1", token.strip, as: User) + if !user + haltf env, status_code: 403 + end + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + 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) + + 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", "type": "text/html", rel: "alternate", href: "#{HOST_URL}/feed/subscriptions") + xml.element("link", "type": "application/atom+xml", rel: "self", + href: "#{HOST_URL}#{env.request.resource}") + xml.element("title") { xml.text translate(locale, "Invidious Private Feed for `x`", user.email) } + + (notifications + videos).each do |video| + video.to_xml(locale, params, xml) + end + end + end + end + + def self.rss_playlist(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + env.response.headers["Content-Type"] = "application/atom+xml" + env.response.content_type = "application/atom+xml" + + plid = env.params.url["plid"] + + params = HTTP::Params.parse(env.params.query["params"]? || "") + 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) + + return 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(false, xml) + end + end + end + else + haltf env, status_code: 404 + end + end + + response = YT_POOL.client &.get("/feeds/videos.xml?playlist_id=#{plid}") + document = XML.parse(response.body) + + document.xpath_nodes(%q(//*[@href]|//*[@url])).each do |node| + node.attributes.each do |attribute| + case attribute.name + when "url", "href" + request_target = URI.parse(node[attribute.name]).request_target + query_string_opt = request_target.starts_with?("/watch?v=") ? "&#{params}" : "" + node[attribute.name] = "#{HOST_URL}#{request_target}#{query_string_opt}" + else nil # Skip + end + end + end + + document = document.to_xml(options: XML::SaveOptions::NO_DECL) + + document.scan(/(?[^<]+)<\/uri>/).each do |match| + content = "#{HOST_URL}#{URI.parse(match["url"]).request_target}" + document = document.gsub(match[0], "#{content}") + end + document + end + + def self.rss_videos(env) + if ucid = env.params.query["channel_id"]? + env.redirect "/feed/channel/#{ucid}" + elsif user = env.params.query["user"]? + env.redirect "/feed/channel/#{user}" + elsif plid = env.params.query["playlist_id"]? + env.redirect "/feed/playlist/#{plid}" + end + end + + # Push notifications via PubSub + + def self.push_notifications_get(env) + verify_token = env.params.url["token"] + + mode = env.params.query["hub.mode"]? + topic = env.params.query["hub.topic"]? + challenge = env.params.query["hub.challenge"]? + + if !mode || !topic || !challenge + haltf env, status_code: 400 + else + mode = mode.not_nil! + topic = topic.not_nil! + challenge = challenge.not_nil! + end + + case verify_token + when .starts_with? "v1" + _, time, nonce, signature = verify_token.split(":") + data = "#{time}:#{nonce}" + when .starts_with? "v2" + time, signature = verify_token.split(":") + data = "#{time}" + else + haltf env, status_code: 400 + end + + # The hub will sometimes check if we're still subscribed after delivery errors, + # so we reply with a 200 as long as the request hasn't expired + if Time.utc.to_unix - time.to_i > 432000 + haltf env, status_code: 400 + end + + if OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, data) != signature + haltf env, status_code: 400 + end + + if ucid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["channel_id"]? + PG_DB.exec("UPDATE channels SET subscribed = $1 WHERE id = $2", Time.utc, ucid) + elsif plid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["playlist_id"]? + PG_DB.exec("UPDATE playlists SET subscribed = $1 WHERE id = $2", Time.utc, ucid) + else + haltf env, status_code: 400 + end + + env.response.status_code = 200 + challenge + end + + def self.push_notifications_post(env) + locale = LOCALES[env.get("preferences").as(Preferences).locale]? + + token = env.params.url["token"] + body = env.request.body.not_nil!.gets_to_end + signature = env.request.headers["X-Hub-Signature"].lchop("sha1=") + + if signature != OpenSSL::HMAC.hexdigest(:sha1, HMAC_KEY, body) + LOGGER.error("/feed/webhook/#{token} : Invalid signature") + haltf env, status_code: 200 + end + + spawn do + rss = XML.parse_html(body) + rss.xpath_nodes("//feed/entry").each do |entry| + id = entry.xpath_node("videoid").not_nil!.content + author = entry.xpath_node("author/name").not_nil!.content + published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) + + video = get_video(id, PG_DB, force_refresh: true) + + # Deliver notifications to `/api/v1/auth/notifications` + payload = { + "topic" => video.ucid, + "videoId" => video.id, + "published" => published.to_unix, + }.to_json + PG_DB.exec("NOTIFY notifications, E'#{payload}'") + + video = ChannelVideo.new({ + id: id, + title: video.title, + published: published, + updated: updated, + ucid: video.ucid, + author: author, + length_seconds: video.length_seconds, + live_now: video.live_now, + premiere_timestamp: video.premiere_timestamp, + views: video.views, + }) + + was_insert = PG_DB.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, + updated = $4, ucid = $5, author = $6, length_seconds = $7, + live_now = $8, premiere_timestamp = $9, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) + + PG_DB.exec("UPDATE users SET notifications = array_append(notifications, $1), + feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert + end + end + + env.response.status_code = 200 + end +end diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index fa548f53..82c40a95 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -17,7 +17,7 @@ module Invidious::Routes::Misc end when "Playlists" if user - env.redirect "/view_all_playlists" + env.redirect "/feed/playlists" else env.redirect "/feed/popular" end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index a2166bdd..05a198d8 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -1,29 +1,4 @@ module Invidious::Routes::Playlists - def self.index(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? - - user = env.get? "user" - referer = get_referer(env) - - return env.redirect "/" if user.nil? - - user = user.as(User) - - items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) - items_created.map! do |item| - item.author = "" - item - end - - items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) - items_saved.map! do |item| - item.author = "" - item - end - - templated "view_all_playlists" - end - def self.new(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? @@ -148,7 +123,7 @@ module Invidious::Routes::Playlists 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" + env.redirect "/feed/playlists" end def self.edit(env) diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr new file mode 100644 index 00000000..40584979 --- /dev/null +++ b/src/invidious/views/feeds/history.ecr @@ -0,0 +1,71 @@ +<% content_for "header" do %> +<%= translate(locale, "History") %> - Invidious +<% end %> + +
+
+

<%= translate(locale, "`x` videos", %(#{user.watched.size})) %>

+
+ + +
+ + + + +
+ <% watched.each do |item| %> + + <% end %> +
+ + diff --git a/src/invidious/views/feeds/playlists.ecr b/src/invidious/views/feeds/playlists.ecr new file mode 100644 index 00000000..868cfeda --- /dev/null +++ b/src/invidious/views/feeds/playlists.ecr @@ -0,0 +1,34 @@ +<% content_for "header" do %> +<%= translate(locale, "Playlists") %> - Invidious +<% end %> + +<%= rendered "components/feed_menu" %> + +
+
+

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

+
+ +
+ +
+<% items_created.each do |item| %> + <%= rendered "components/item" %> +<% end %> +
+ +
+
+

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

+
+
+ +
+<% items_saved.each do |item| %> + <%= rendered "components/item" %> +<% end %> +
diff --git a/src/invidious/views/feeds/popular.ecr b/src/invidious/views/feeds/popular.ecr new file mode 100644 index 00000000..e77f35b9 --- /dev/null +++ b/src/invidious/views/feeds/popular.ecr @@ -0,0 +1,18 @@ +<% content_for "header" do %> +"> + + <% if env.get("preferences").as(Preferences).default_home != "Popular" %> + <%= translate(locale, "Popular") %> - Invidious + <% else %> + Invidious + <% end %> + +<% end %> + +<%= rendered "components/feed_menu" %> + +
+<% popular_videos.each do |item| %> + <%= rendered "components/item" %> +<% end %> +
diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr new file mode 100644 index 00000000..97184e2b --- /dev/null +++ b/src/invidious/views/feeds/subscriptions.ecr @@ -0,0 +1,77 @@ +<% content_for "header" do %> +<%= translate(locale, "Subscriptions") %> - Invidious + +<% end %> + +<%= rendered "components/feed_menu" %> + + + +
+ <%= translate(locale, "`x` unseen notifications", "#{notifications.size}") %> +
+ +<% if !notifications.empty? %> +
+
+
+<% end %> + +
+<% notifications.each do |item| %> + <%= rendered "components/item" %> +<% end %> +
+ +
+
+
+ + + + +
+<% videos.each do |item| %> + <%= rendered "components/item" %> +<% end %> +
+ +
+ +
+
+ <% if (videos.size + notifications.size) == max_results %> + &max_results=<%= max_results %><% end %>"> + <%= translate(locale, "Next page") %> + + <% end %> +
+
diff --git a/src/invidious/views/feeds/trending.ecr b/src/invidious/views/feeds/trending.ecr new file mode 100644 index 00000000..a35c4ee3 --- /dev/null +++ b/src/invidious/views/feeds/trending.ecr @@ -0,0 +1,47 @@ +<% content_for "header" do %> +"> + + <% if env.get("preferences").as(Preferences).default_home != "Trending" %> + <%= translate(locale, "Trending") %> - Invidious + <% else %> + Invidious + <% end %> + +<% end %> + +<%= rendered "components/feed_menu" %> + +
+ +
+
+ <% {"Default", "Music", "Gaming", "Movies"}.each do |option| %> +
+ <% if trending_type == option %> + <%= translate(locale, option) %> + <% else %> + + <%= translate(locale, option) %> + + <% end %> +
+ <% end %> +
+
+
+ +
+
+
+ +
+<% trending.each do |item| %> + <%= rendered "components/item" %> +<% end %> +
diff --git a/src/invidious/views/history.ecr b/src/invidious/views/history.ecr deleted file mode 100644 index 40584979..00000000 --- a/src/invidious/views/history.ecr +++ /dev/null @@ -1,71 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "History") %> - Invidious -<% end %> - -
-
-

<%= translate(locale, "`x` videos", %(#{user.watched.size})) %>

-
- - -
- - - - -
- <% watched.each do |item| %> - - <% end %> -
- - diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index b1fee211..12f93a72 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -12,7 +12,7 @@ <% if playlist.is_a? InvidiousPlaylist %> <% if playlist.author == user.try &.email %> - <%= author %> | + <%= author %> | <% else %> <%= author %> | <% end %> diff --git a/src/invidious/views/popular.ecr b/src/invidious/views/popular.ecr deleted file mode 100644 index e77f35b9..00000000 --- a/src/invidious/views/popular.ecr +++ /dev/null @@ -1,18 +0,0 @@ -<% content_for "header" do %> -"> - - <% if env.get("preferences").as(Preferences).default_home != "Popular" %> - <%= translate(locale, "Popular") %> - Invidious - <% else %> - Invidious - <% end %> - -<% end %> - -<%= rendered "components/feed_menu" %> - -
-<% popular_videos.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index d98c3bb5..be021c59 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -312,7 +312,7 @@

diff --git a/src/invidious/views/subscriptions.ecr b/src/invidious/views/subscriptions.ecr deleted file mode 100644 index 97184e2b..00000000 --- a/src/invidious/views/subscriptions.ecr +++ /dev/null @@ -1,77 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Subscriptions") %> - Invidious - -<% end %> - -<%= rendered "components/feed_menu" %> - - - -
- <%= translate(locale, "`x` unseen notifications", "#{notifications.size}") %> -
- -<% if !notifications.empty? %> -
-
-
-<% end %> - -
-<% notifications.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
- -
-
-
- - - - -
-<% videos.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
- -
- -
-
- <% if (videos.size + notifications.size) == max_results %> - &max_results=<%= max_results %><% end %>"> - <%= translate(locale, "Next page") %> - - <% end %> -
-
diff --git a/src/invidious/views/trending.ecr b/src/invidious/views/trending.ecr deleted file mode 100644 index a35c4ee3..00000000 --- a/src/invidious/views/trending.ecr +++ /dev/null @@ -1,47 +0,0 @@ -<% content_for "header" do %> -"> - - <% if env.get("preferences").as(Preferences).default_home != "Trending" %> - <%= translate(locale, "Trending") %> - Invidious - <% else %> - Invidious - <% end %> - -<% end %> - -<%= rendered "components/feed_menu" %> - -
- -
-
- <% {"Default", "Music", "Gaming", "Movies"}.each do |option| %> -
- <% if trending_type == option %> - <%= translate(locale, option) %> - <% else %> - - <%= translate(locale, option) %> - - <% end %> -
- <% end %> -
-
-
- -
-
-
- -
-<% trending.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
diff --git a/src/invidious/views/view_all_playlists.ecr b/src/invidious/views/view_all_playlists.ecr deleted file mode 100644 index 868cfeda..00000000 --- a/src/invidious/views/view_all_playlists.ecr +++ /dev/null @@ -1,34 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Playlists") %> - Invidious -<% end %> - -<%= rendered "components/feed_menu" %> - -
-
-

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

-
- -
- -
-<% items_created.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
- -
-
-

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

-
-
- -
-<% items_saved.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
-- cgit v1.2.3 From 8e3ff79f22f4eac38d46bd9769b19cb87fdaf838 Mon Sep 17 00:00:00 2001 From: TheFrenchGhosty Date: Wed, 1 Sep 2021 12:23:50 +0200 Subject: Remove the mention of 'Omar Roth' from the footer --- locales/ar.json | 2 +- locales/bn_BD.json | 2 +- locales/cs.json | 2 +- locales/da.json | 2 +- locales/de.json | 2 +- locales/el.json | 2 +- locales/en-US.json | 2 +- locales/eo.json | 2 +- locales/es.json | 2 +- locales/eu.json | 2 +- locales/fa.json | 2 +- locales/fi.json | 2 +- locales/fr.json | 2 +- locales/he.json | 2 +- locales/hr.json | 2 +- locales/hu-HU.json | 2 +- locales/id.json | 2 +- locales/is.json | 2 +- locales/it.json | 2 +- locales/ja.json | 2 +- locales/ko.json | 2 +- locales/lt.json | 2 +- locales/nb-NO.json | 2 +- locales/nl.json | 2 +- locales/pl.json | 2 +- locales/pt-BR.json | 2 +- locales/pt-PT.json | 2 +- locales/ro.json | 2 +- locales/ru.json | 2 +- locales/si.json | 2 +- locales/sk.json | 2 +- locales/sr.json | 2 +- locales/sr_Cyrl.json | 2 +- locales/sv-SE.json | 2 +- locales/tr.json | 2 +- locales/uk.json | 2 +- locales/vi.json | 2 +- locales/zh-CN.json | 2 +- locales/zh-TW.json | 2 +- src/invidious/views/template.ecr | 2 +- 40 files changed, 40 insertions(+), 40 deletions(-) (limited to 'src') diff --git a/locales/ar.json b/locales/ar.json index b5e77517..4393fb7c 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -145,7 +145,7 @@ }, "search": "بحث", "Log out": "تسجيل الخروج", - "Released under the AGPLv3 by Omar Roth.": "تم الإنشاء تحت AGPLv3 بواسطة عمر روث.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "الأكواد متوفرة هنا.", "View JavaScript license information.": "مشاهدة معلومات حول تراخيص الجافاسكريبت.", "View privacy policy.": "عرض سياسة الخصوصية.", diff --git a/locales/bn_BD.json b/locales/bn_BD.json index 5f91c67e..c9e1150b 100644 --- a/locales/bn_BD.json +++ b/locales/bn_BD.json @@ -145,7 +145,7 @@ }, "search": "", "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", + "Released under the AGPLv3 on Github.": "", "Source available here.": "", "View JavaScript license information.": "", "View privacy policy.": "", diff --git a/locales/cs.json b/locales/cs.json index abb2d503..094ad09c 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -145,7 +145,7 @@ }, "search": "hledat", "Log out": "Odhlásit se", - "Released under the AGPLv3 by Omar Roth.": "Vydáno Omarem Roth pod AGPLv3.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Zdrojový kód dostupný zde.", "View JavaScript license information.": "Zobrazit informace o licenci JavaScript .", "View privacy policy.": "Zobrazit Zásady ochrany osobních údajů.", diff --git a/locales/da.json b/locales/da.json index 2d0dad84..5919283d 100644 --- a/locales/da.json +++ b/locales/da.json @@ -145,7 +145,7 @@ }, "search": "søg", "Log out": "Log ud", - "Released under the AGPLv3 by Omar Roth.": "Offentliggjort under AGPLv3 af Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Kilde tilgængelig her.", "View JavaScript license information.": "Vis JavaScriptlicensinformation.", "View privacy policy.": "Vis privatpolitik.", diff --git a/locales/de.json b/locales/de.json index 6f2e36da..44725cbc 100644 --- a/locales/de.json +++ b/locales/de.json @@ -145,7 +145,7 @@ }, "search": "Suchen", "Log out": "Abmelden", - "Released under the AGPLv3 by Omar Roth.": "Veröffentlicht unter AGPLv3 von Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Quellcode verfügbar hier.", "View JavaScript license information.": "Javascript Lizenzinformationen anzeigen.", "View privacy policy.": "Datenschutzerklärung einsehen.", diff --git a/locales/el.json b/locales/el.json index 830fb0fe..6ad0c47f 100644 --- a/locales/el.json +++ b/locales/el.json @@ -145,7 +145,7 @@ }, "search": "αναζήτηση", "Log out": "Αποσύνδεση", - "Released under the AGPLv3 by Omar Roth.": "Κυκλοφορεί υπό την άδεια AGPLv3 από τον Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Προβολή πηγαίου κώδικα εδώ.", "View JavaScript license information.": "Προβολή πληροφοριών άδειας JavaScript.", "View privacy policy.": "Προβολή πολιτικής απορρήτου.", diff --git a/locales/en-US.json b/locales/en-US.json index 0836409e..a1e39777 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -145,7 +145,7 @@ }, "search": "search", "Log out": "Log out", - "Released under the AGPLv3 by Omar Roth.": "Released under the AGPLv3 by Omar Roth.", + "Released under the AGPLv3 on Github.": "Released under the AGPLv3 on Github.", "Source available here.": "Source available here.", "View JavaScript license information.": "View JavaScript license information.", "View privacy policy.": "View privacy policy.", diff --git a/locales/eo.json b/locales/eo.json index 05359382..56a2681c 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -145,7 +145,7 @@ }, "search": "serĉi", "Log out": "Elsaluti", - "Released under the AGPLv3 by Omar Roth.": "Eldonita sub la AGPLv3 de Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Fonto havebla ĉi tie.", "View JavaScript license information.": "Vidi Ĝavoskriptan licencan informon.", "View privacy policy.": "Vidi regularon pri privateco.", diff --git a/locales/es.json b/locales/es.json index dfeb69b8..9d455413 100644 --- a/locales/es.json +++ b/locales/es.json @@ -145,7 +145,7 @@ }, "search": "buscar", "Log out": "Cerrar la sesión", - "Released under the AGPLv3 by Omar Roth.": "Publicado bajo licencia AGPLv3 por Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Código fuente disponible aquí.", "View JavaScript license information.": "Ver información de licencia de JavaScript.", "View privacy policy.": "Ver la política de privacidad.", diff --git a/locales/eu.json b/locales/eu.json index 2fdb278b..df3f4329 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -145,7 +145,7 @@ }, "search": "", "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", + "Released under the AGPLv3 on Github.": "", "Source available here.": "", "View JavaScript license information.": "", "View privacy policy.": "", diff --git a/locales/fa.json b/locales/fa.json index d449948a..68a016c4 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -145,7 +145,7 @@ }, "search": "جستجو", "Log out": "خروج", - "Released under the AGPLv3 by Omar Roth.": "منتشر شده تحت مجوز AGPLv3 توسط Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "منبع اینجا دردسترس است.", "View JavaScript license information.": "نمایش اطلاعات مجوز جاوا اسکریپت.", "View privacy policy.": "نمایش سیاست حفظ حریم خصوصی.", diff --git a/locales/fi.json b/locales/fi.json index b7a1af87..6a830177 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -145,7 +145,7 @@ }, "search": "haku", "Log out": "Kirjaudu ulos", - "Released under the AGPLv3 by Omar Roth.": "Julkaissut AGPLv3-lisenssillä: Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Lähdekoodi on saatavilla täällä.", "View JavaScript license information.": "JavaScript-koodin lisenssit.", "View privacy policy.": "Katso tietosuojaseloste.", diff --git a/locales/fr.json b/locales/fr.json index 80760fce..bdf1f2f4 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -145,7 +145,7 @@ }, "search": "rechercher", "Log out": "Se déconnecter", - "Released under the AGPLv3 by Omar Roth.": "Publié sous licence AGPLv3 par Omar Roth.", + "Released under the AGPLv3 on Github.": "Publié sous licence AGPLv3 par Omar Roth.", "Source available here.": "Code source disponible ici.", "View JavaScript license information.": "Informations des licences JavaScript.", "View privacy policy.": "Politique de confidentialité.", diff --git a/locales/he.json b/locales/he.json index 5d7f85c6..6778e4dd 100644 --- a/locales/he.json +++ b/locales/he.json @@ -145,7 +145,7 @@ }, "search": "חיפוש", "Log out": "יציאה", - "Released under the AGPLv3 by Omar Roth.": "מופץ תחת רישיון AGPLv3 על ידי עמר רות׳ (Omar Roth).", + "Released under the AGPLv3 on Github.": "", "Source available here.": "קוד המקור זמין כאן.", "View JavaScript license information.": "", "View privacy policy.": "להצגת מדיניות הפרטיות.", diff --git a/locales/hr.json b/locales/hr.json index d4a31323..dd6d14a9 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -145,7 +145,7 @@ }, "search": "traži", "Log out": "Odjavi se", - "Released under the AGPLv3 by Omar Roth.": "Izdano pod licencom AGPLv3, Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Izvor je ovdje dostupan.", "View JavaScript license information.": "Prikaži informacije o JavaScript licenci.", "View privacy policy.": "Prikaži politiku privatnosti.", diff --git a/locales/hu-HU.json b/locales/hu-HU.json index d69a0792..d5570a18 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -145,7 +145,7 @@ }, "search": "keresés", "Log out": "Kijelentkezés", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth által kiadva AGPLv3 licensz alatt.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "A forráskód itt érhető el.", "View JavaScript license information.": "JavaScript licensz inforkációk megtekintése.", "View privacy policy.": "Adatvédelmi irányelvek megtekintése.", diff --git a/locales/id.json b/locales/id.json index 9ca7ba0f..e15c6aaf 100644 --- a/locales/id.json +++ b/locales/id.json @@ -145,7 +145,7 @@ }, "search": "cari", "Log out": "Keluar", - "Released under the AGPLv3 by Omar Roth.": "Dirilis dibawah AGPLv3 oleh Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Sumber tersedia di sini.", "View JavaScript license information.": "Tampilkan informasi lisensi JavaScript.", "View privacy policy.": "Lihat kebijakan privasi.", diff --git a/locales/is.json b/locales/is.json index 827abaeb..478f363a 100644 --- a/locales/is.json +++ b/locales/is.json @@ -145,7 +145,7 @@ }, "search": "leita", "Log out": "Útskrá", - "Released under the AGPLv3 by Omar Roth.": "Útgefið undir AGPLv3 eftir Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Frumkóði aðgengilegur hér.", "View JavaScript license information.": "Skoða JavaScript leyfisupplýsingar.", "View privacy policy.": "Skoða meðferð persónuupplýsinga.", diff --git a/locales/it.json b/locales/it.json index 676bd650..df3642db 100644 --- a/locales/it.json +++ b/locales/it.json @@ -145,7 +145,7 @@ }, "search": "Cerca", "Log out": "Esci", - "Released under the AGPLv3 by Omar Roth.": "Pubblicato con licenza AGPLv3 da Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Codice sorgente.", "View JavaScript license information.": "Guarda le informazioni di licenza del codice JavaScript.", "View privacy policy.": "Vedi la politica sulla privacy.", diff --git a/locales/ja.json b/locales/ja.json index 831671c6..1e418094 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -145,7 +145,7 @@ }, "search": "検索", "Log out": "ログアウト", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth によって AGPLv3 でリリースされています", + "Released under the AGPLv3 on Github.": "", "Source available here.": "ソースはここで閲覧可能です。", "View JavaScript license information.": "JavaScript ライセンス情報", "View privacy policy.": "プライバシーポリシー", diff --git a/locales/ko.json b/locales/ko.json index ba03d093..94f781d4 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -136,6 +136,7 @@ "Delete playlist": "재생목록 삭제", "Delete playlist `x`?": "재생목록 `x` 를 삭제 하시겠습니까?", "Updated `x` ago": "`x` 전에 업데이트됨", + "Released under the AGPLv3 on Github.": "", "View all playlists": "모든 재생목록 보기", "Private": "비공개", "Unlisted": "목록에 없음", @@ -143,7 +144,6 @@ "View privacy policy.": "개인정보 처리방침 보기.", "View JavaScript license information.": "JavaScript 라이센스 정보 보기.", "Source available here.": "소스는 여기에서 사용할 수 있습니다.", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth에 의해 AGPLv3에 따라 공개되었습니다.", "Log out": "로그아웃", "search": "검색", "`x` unseen notifications": { diff --git a/locales/lt.json b/locales/lt.json index 94b4556a..ae0ee0a3 100644 --- a/locales/lt.json +++ b/locales/lt.json @@ -145,7 +145,7 @@ }, "search": "ieškoti", "Log out": "Atsijungti", - "Released under the AGPLv3 by Omar Roth.": "Išleista pagal AGPLv3 - Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Kodas prieinamas čia.", "View JavaScript license information.": "Žiūrėti JavaScript licencijos informaciją.", "View privacy policy.": "Žiūrėti privatumo politiką.", diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 5088baff..9e39a6c7 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -145,7 +145,7 @@ }, "search": "søk", "Log out": "Logg ut", - "Released under the AGPLv3 by Omar Roth.": "Utgitt med AGPLv3+lisens av Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Kildekode tilgjengelig her.", "View JavaScript license information.": "Vis JavaScript-lisensinfo.", "View privacy policy.": "Vis personvernspraksis.", diff --git a/locales/nl.json b/locales/nl.json index c4948fd1..9fe604ad 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -145,7 +145,7 @@ }, "search": "zoeken", "Log out": "Uitloggen", - "Released under the AGPLv3 by Omar Roth.": "Uitgebracht onder de AGPLv3-licentie, door Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "De broncode is hier beschikbaar.", "View JavaScript license information.": "JavaScript-licentieinformatie tonen.", "View privacy policy.": "Privacybeleid tonen.", diff --git a/locales/pl.json b/locales/pl.json index 0f86154e..a33bbd45 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -145,7 +145,7 @@ }, "search": "szukaj", "Log out": "Wyloguj", - "Released under the AGPLv3 by Omar Roth.": "Wydano na licencji AGPLv3 przez Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Kod źródłowy dostępny tutaj.", "View JavaScript license information.": "Wyświetl informację o licencji JavaScript.", "View privacy policy.": "Polityka prywatności.", diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 12763efc..16725774 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -145,7 +145,7 @@ }, "search": "Pesquisar", "Log out": "Sair", - "Released under the AGPLv3 by Omar Roth.": "Publicado sob a licença AGPLv3, por Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Código-fonte disponível aqui.", "View JavaScript license information.": "Ver informações da licença do JavaScript.", "View privacy policy.": "Ver a política de privacidade.", diff --git a/locales/pt-PT.json b/locales/pt-PT.json index 704a105f..a5e4bca8 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -145,7 +145,7 @@ }, "search": "Pesquisar", "Log out": "Terminar sessão", - "Released under the AGPLv3 by Omar Roth.": "Publicado sob a licença AGPLv3, por Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Código-fonte disponível aqui.", "View JavaScript license information.": "Ver informações da licença do JavaScript.", "View privacy policy.": "Ver a política de privacidade.", diff --git a/locales/ro.json b/locales/ro.json index ce961c39..a8877853 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -145,7 +145,7 @@ }, "search": "căutați", "Log out": "Deconectați-vă", - "Released under the AGPLv3 by Omar Roth.": "Publicat sub licența AGPLv3 de Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Codul sursă este disponibil aici.", "View JavaScript license information.": "Informații legate de licența JavaScript.", "View privacy policy.": "Politica de confidențialitate.", diff --git a/locales/ru.json b/locales/ru.json index 4896b3d0..d26cd058 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -145,7 +145,7 @@ }, "search": "поиск", "Log out": "Выйти", - "Released under the AGPLv3 by Omar Roth.": "Реализовано Омаром Ротом по лицензии AGPLv3.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Исходный код доступен здесь.", "View JavaScript license information.": "Посмотреть информацию по лицензии JavaScript.", "View privacy policy.": "Посмотреть политику конфиденциальности.", diff --git a/locales/si.json b/locales/si.json index f59629d0..f38c56b7 100644 --- a/locales/si.json +++ b/locales/si.json @@ -145,7 +145,7 @@ }, "search": "", "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", + "Released under the AGPLv3 on Github.": "", "Source available here.": "", "View JavaScript license information.": "", "View privacy policy.": "", diff --git a/locales/sk.json b/locales/sk.json index 32df0569..cdeca6c0 100644 --- a/locales/sk.json +++ b/locales/sk.json @@ -145,7 +145,7 @@ }, "search": "", "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", + "Released under the AGPLv3 on Github.": "", "Source available here.": "", "View JavaScript license information.": "", "View privacy policy.": "", diff --git a/locales/sr.json b/locales/sr.json index 83cc12c1..314f0367 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -145,7 +145,7 @@ }, "search": "", "Log out": "", - "Released under the AGPLv3 by Omar Roth.": "", + "Released under the AGPLv3 on Github.": "", "Source available here.": "", "View JavaScript license information.": "", "View privacy policy.": "", diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index 92cfd103..056b79cb 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -145,7 +145,7 @@ }, "search": "претрага", "Log out": "Одјавите се", - "Released under the AGPLv3 by Omar Roth.": "Издао Омар Рот (Omar Roth) под условима AGPLv3 лиценце.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Изворни код доступан овде.", "View JavaScript license information.": "Прикажи информације о JavaScript лиценци.", "View privacy policy.": "Прикажи извештај о приватности.", diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 42699093..ae8e6fc4 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -145,7 +145,7 @@ }, "search": "sök", "Log out": "Logga ut", - "Released under the AGPLv3 by Omar Roth.": "Utgiven under AGPLv3-licens av Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Källkod tillgänglig här.", "View JavaScript license information.": "Visa JavaScript-licensinformation.", "View privacy policy.": "Visa privatlivspolicy.", diff --git a/locales/tr.json b/locales/tr.json index 01bb2ead..4d165909 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -145,7 +145,7 @@ }, "search": "ara", "Log out": "Çıkış yap", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth tarafından AGPLv3 altında yayımlandı.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Kaynak kodları burada bulunabilir.", "View JavaScript license information.": "JavaScript lisans bilgilerini görüntüle.", "View privacy policy.": "Gizlilik politikasını görüntüle.", diff --git a/locales/uk.json b/locales/uk.json index e51aa5ba..5100206c 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -145,7 +145,7 @@ }, "search": "пошук", "Log out": "Вийти", - "Released under the AGPLv3 by Omar Roth.": "Реалізовано Омаром Ротом за ліцензією AGPLv3.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Програмний код доступний тут.", "View JavaScript license information.": "Переглянути інформацію щодо ліцензії JavaScript.", "View privacy policy.": "Переглянути політику приватності.", diff --git a/locales/vi.json b/locales/vi.json index d2e38ff6..5a2812f7 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -145,7 +145,7 @@ }, "search": "Tìm kiếm", "Log out": "Đăng xuất", - "Released under the AGPLv3 by Omar Roth.": "Được phát hành theo AGPLv3 bởi Omar Roth.", + "Released under the AGPLv3 on Github.": "", "Source available here.": "Nguồn có sẵn ở đây.", "View JavaScript license information.": "Xem thông tin giấy phép JavaScript.", "View privacy policy.": "Xem chính sách bảo mật.", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 4c1f9eae..77101362 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -145,7 +145,7 @@ }, "search": "搜索", "Log out": "登出", - "Released under the AGPLv3 by Omar Roth.": "由 Omar Roth 开发,以 AGPLv3 授权。", + "Released under the AGPLv3 on Github.": "", "Source available here.": "源码可在此查看。", "View JavaScript license information.": "查看 JavaScript 协议信息。", "View privacy policy.": "查看隐私政策。", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index e554b23a..e39e9470 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -145,7 +145,7 @@ }, "search": "搜尋", "Log out": "登出", - "Released under the AGPLv3 by Omar Roth.": "Omar Roth 以 AGPLv3 釋出。", + "Released under the AGPLv3 on Github.": "", "Source available here.": "原始碼在此提供。", "View JavaScript license information.": "檢視 JavaScript 授權條款資訊。", "View privacy policy.": "檢視隱私權政策。", diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index d0bdd742..7be95959 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -118,7 +118,7 @@
-- cgit v1.2.3 From a1001ada479c4e6ad03fa34b74f035d171b24ce5 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Wed, 1 Sep 2021 10:59:47 +0000 Subject: Properly transform youtu.be links to be /watch routes in comments and descriptions (#2365) --- src/invidious/comments.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 141a526d..3a4328a5 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -552,7 +552,9 @@ def content_to_comment_html(content) if url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s url = URI.parse(url) - if !url.host || {"m.youtube.com", "www.youtube.com", "youtu.be"}.includes? url.host + if url.host == "youtu.be" + url = "/watch?v=#{url.request_target.lstrip('/')}" + elsif !url.host || {"m.youtube.com", "www.youtube.com"}.includes? url if url.path == "/redirect" url = HTTP::Params.parse(url.query.not_nil!)["q"] else -- cgit v1.2.3 From 8b62c05fe2df979bd1d1b53cde4b86ad318cb760 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Fri, 3 Sep 2021 09:39:11 +0200 Subject: remove 3gp only from the player (#2376) + video quality precedence on default player when js is not enabled --- src/invidious/views/components/player.ecr | 2 ++ 1 file changed, 2 insertions(+) (limited to 'src') diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index 03252418..c520fb5a 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -16,6 +16,8 @@ <% end %> <% + fmt_stream.reject! { |f| f["itag"] == 17 } + fmt_stream.sort_by! {|f| params.quality == f["quality"] ? 0 : 1 } fmt_stream.each_with_index do |fmt, i| src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" src_url += "&local=true" if params.local -- cgit v1.2.3 From fd6f03655eacdbd1dc3718017bb42793ee77fd47 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 3 Sep 2021 03:30:36 -0700 Subject: Fix typo causing links to be youtube.com/redirect --- src/invidious/comments.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 3a4328a5..6dc27639 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -554,7 +554,7 @@ def content_to_comment_html(content) if url.host == "youtu.be" url = "/watch?v=#{url.request_target.lstrip('/')}" - elsif !url.host || {"m.youtube.com", "www.youtube.com"}.includes? url + elsif !url.host || {"m.youtube.com", "www.youtube.com"}.includes? url.host if url.path == "/redirect" url = HTTP::Params.parse(url.query.not_nil!)["q"] else -- cgit v1.2.3 From a28945273d99607b92eea1f05f57d7e1874fc20d Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 3 Sep 2021 03:33:49 -0700 Subject: Propagate replacing yout.be links to /watch to RSS --- src/invidious/comments.cr | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 6dc27639..5f607524 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -481,11 +481,15 @@ def replace_links(html) url = URI.parse(anchor["href"]) if {"www.youtube.com", "m.youtube.com", "youtu.be"}.includes?(url.host) - if url.path == "/redirect" - params = HTTP::Params.parse(url.query.not_nil!) - anchor["href"] = params["q"]? + if url.host == "youtu.be" + url = "/watch?v=#{url.request_target.lstrip('/')}" else - anchor["href"] = url.request_target + if url.path == "/redirect" + params = HTTP::Params.parse(url.query.not_nil!) + anchor["href"] = params["q"]? + else + anchor["href"] = url.request_target + end end elsif url.to_s == "#" begin -- cgit v1.2.3 From 41ba19b615b95d3c881b44131f1c61479dd157b7 Mon Sep 17 00:00:00 2001 From: Emilien Devos Date: Sat, 4 Sep 2021 01:18:46 +0200 Subject: fix comment replies --- src/invidious/comments.cr | 57 ++++++++++++++++++++++++++++++----------------- 1 file changed, 36 insertions(+), 21 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 3a4328a5..a5617796 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -70,8 +70,24 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b client_config = YoutubeAPI::ClientConfig.new(region: region) response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) + contents = nil - if response["continuationContents"]? + if response["onResponseReceivedEndpoints"]? + onResponseReceivedEndpoints = response["onResponseReceivedEndpoints"] + header = nil + onResponseReceivedEndpoints.as_a.each do |item| + if item["reloadContinuationItemsCommand"]? + case item["reloadContinuationItemsCommand"]["slot"] + when "RELOAD_CONTINUATION_SLOT_HEADER" + header = item["reloadContinuationItemsCommand"]["continuationItems"][0] + when "RELOAD_CONTINUATION_SLOT_BODY" + contents = item["reloadContinuationItemsCommand"]["continuationItems"] + end + elsif item["appendContinuationItemsAction"]? + contents = item["appendContinuationItemsAction"]["continuationItems"] + end + end + elsif response["continuationContents"]? response = response["continuationContents"] if response["commentRepliesContinuation"]? body = response["commentRepliesContinuation"] @@ -83,22 +99,6 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b if body["continuations"]? moreRepliesContinuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s end - elsif response["onResponseReceivedEndpoints"]? - onResponseReceivedEndpoints = response["onResponseReceivedEndpoints"] - onResponseReceivedEndpoints.as_a.each do |item| - case item["reloadContinuationItemsCommand"]["slot"] - when "RELOAD_CONTINUATION_SLOT_HEADER" - header = item["reloadContinuationItemsCommand"]["continuationItems"][0] - when "RELOAD_CONTINUATION_SLOT_BODY" - contents = item["reloadContinuationItemsCommand"]["continuationItems"] - contents.as_a.reject! do |item| - if item["continuationItemRenderer"]? - moreRepliesContinuation = item["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s - true - end - end - end - end else raise InfoException.new("Could not fetch comments") end @@ -111,6 +111,14 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b end end + continuationItemRenderer = nil + contents.as_a.reject! do |item| + if item["continuationItemRenderer"]? + continuationItemRenderer = item["continuationItemRenderer"] + true + end + end + response = JSON.build do |json| json.object do if header @@ -126,7 +134,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b json.array do contents.as_a.each do |node| json.object do - if !response["commentRepliesContinuation"]? + if node["commentThreadRenderer"]? node = node["commentThreadRenderer"] end @@ -134,7 +142,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b node_replies = node["replies"]["commentRepliesRenderer"] end - if !response["commentRepliesContinuation"]? + if node["comment"]? node_comment = node["comment"]["commentRenderer"] else node_comment = node["commentRenderer"] @@ -224,8 +232,15 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b end end - if moreRepliesContinuation - json.field "continuation", moreRepliesContinuation + if continuationItemRenderer + if continuationItemRenderer["continuationEndpoint"]? + continuationEndpoint = continuationItemRenderer["continuationEndpoint"] + elsif continuationItemRenderer["button"]? + continuationEndpoint = continuationItemRenderer["button"]["buttonRenderer"]["command"] + end + if continuationEndpoint + json.field "continuation", continuationEndpoint["continuationCommand"]["token"].as_s + end end end end -- cgit v1.2.3 From 387bddb51bfdea6a0679d1bd7bcccd2079105aae Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 3 Sep 2021 12:28:34 -0700 Subject: Improve detection and handling of yt redirect links --- src/invidious/comments.cr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 5f607524..57fba565 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -480,9 +480,9 @@ def replace_links(html) html.xpath_nodes(%q(//a)).each do |anchor| url = URI.parse(anchor["href"]) - if {"www.youtube.com", "m.youtube.com", "youtu.be"}.includes?(url.host) - if url.host == "youtu.be" - url = "/watch?v=#{url.request_target.lstrip('/')}" + if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be") + if url.host.try &.ends_with? "youtu.be" + url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}" else if url.path == "/redirect" params = HTTP::Params.parse(url.query.not_nil!) @@ -558,7 +558,7 @@ def content_to_comment_html(content) if url.host == "youtu.be" url = "/watch?v=#{url.request_target.lstrip('/')}" - elsif !url.host || {"m.youtube.com", "www.youtube.com"}.includes? url.host + elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") if url.path == "/redirect" url = HTTP::Params.parse(url.query.not_nil!)["q"] else -- cgit v1.2.3 From a539de4f9729594bc2a8a18cc9eed642582b7d67 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Fri, 10 Sep 2021 07:42:15 +0000 Subject: Bump dependencies (#2378) * Upgrade to Kemal v1.1.0 * Bump postgres driver --- shard.lock | 12 ++++++++---- shard.yml | 4 ++-- src/invidious.cr | 1 + 3 files changed, 11 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/shard.lock b/shard.lock index bfb54ee1..21ea9919 100644 --- a/shard.lock +++ b/shard.lock @@ -4,21 +4,25 @@ shards: git: https://github.com/athena-framework/negotiation.git version: 0.1.1 + backtracer: + git: https://github.com/sija/backtracer.cr.git + version: 1.2.1 + db: git: https://github.com/crystal-lang/crystal-db.git version: 0.10.1 exception_page: git: https://github.com/crystal-loot/exception_page.git - version: 0.1.5 + version: 0.2.0 kemal: git: https://github.com/kemalcr/kemal.git - version: 1.0.0 + version: 1.1.0 kilt: git: https://github.com/jeromegn/kilt.git - version: 0.4.1 + version: 0.6.1 lsquic: git: https://github.com/iv-org/lsquic.cr.git @@ -26,7 +30,7 @@ shards: pg: git: https://github.com/will/crystal-pg.git - version: 0.23.2 + version: 0.24.0 protodec: git: https://github.com/iv-org/protodec.git diff --git a/shard.yml b/shard.yml index 3292e505..b32054e6 100644 --- a/shard.yml +++ b/shard.yml @@ -12,13 +12,13 @@ targets: dependencies: pg: github: will/crystal-pg - version: ~> 0.23.2 + version: ~> 0.24.0 sqlite3: github: crystal-lang/crystal-sqlite3 version: ~> 0.18.0 kemal: github: kemalcr/kemal - version: ~> 1.0.0 + version: ~> 1.1.0 protodec: github: iv-org/protodec version: ~> 0.1.4 diff --git a/src/invidious.cr b/src/invidious.cr index 27ebd735..5ad2dd91 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -1550,4 +1550,5 @@ add_context_storage_type(User) Kemal.config.logger = LOGGER Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port +Kemal.config.app_name = "Invidious" Kemal.run -- cgit v1.2.3 From 50c8afb525429dcdb5a9b9bb4cf798ee9f62da2a Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Fri, 10 Sep 2021 07:42:39 +0000 Subject: Handle equirectangular projections for VR (#2379) --- assets/js/player.js | 7 ++++++- src/invidious/videos.cr | 7 ++++++- src/invidious/views/watch.ecr | 3 ++- 3 files changed, 14 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index 0de18d92..a8a75f6e 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -151,7 +151,12 @@ player.on('error', function (event) { // Enable VR video support if (video_data.vr && video_data.params.vr_mode) { player.crossOrigin("anonymous") - player.vr({projection: "EAC"}); + switch (video_data.projection_type) { + case "EQUIRECTANGULAR": + player.vr({projection: "equirectangular"}); + default: // Should only be "MESH" but we'll use this as a fallback. + player.vr({projection: "EAC"}); + } } // Add markers diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 27d85b92..d9c07142 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -762,7 +762,12 @@ struct Video end def is_vr : Bool? - info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s == "MESH" + projection_type = info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s + return {"EQUIRECTANGULAR", "MESH"}.includes? projection_type + end + + def projection_type : String? + return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s end def wilson_score : Float64 diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index aeb0f476..68e7eb80 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -63,7 +63,8 @@ we're going to need to do it here in order to allow for translations. "params" => params, "preferences" => preferences, "premiere_timestamp" => video.premiere_timestamp.try &.to_unix, - "vr" => video.is_vr + "vr" => video.is_vr, + "projection_type" => video.projection_type }.to_pretty_json %> -- cgit v1.2.3 From 947fe4fbb3f4c51820b8d07844579c2894eaee4f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 13 Sep 2021 18:20:11 +0200 Subject: HTML escape video mimetype Video mimetype may contain code information between double quotes. If not properly escaped, it breaks the browser's parser. E.g: ``` type="video/mp4; codecs=" avc1.64001f,="" mp4a.40.2""="" ``` Thank Robin for catching this! --- src/invidious/views/components/player.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index c520fb5a..6418f66b 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -23,7 +23,7 @@ src_url += "&local=true" if params.local quality = fmt["quality"] - mimetype = fmt["mimeType"] + mimetype = HTML.escape(fmt["mimeType"].as_s) selected = params.quality ? (params.quality == quality) : (i == 0) %> -- cgit v1.2.3 From 5054510d15d58f4f72e4c004384a90ca61e08c63 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Tue, 14 Sep 2021 23:37:23 +0000 Subject: Prevent VR from being initialized in listen mode (#2396) --- assets/js/player.js | 2 +- src/invidious/views/components/player_sources.ecr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index a8a75f6e..a461c53d 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -149,7 +149,7 @@ player.on('error', function (event) { }); // Enable VR video support -if (video_data.vr && video_data.params.vr_mode) { +if (!video_data.params.listen && video_data.vr && video_data.params.vr_mode) { player.crossOrigin("anonymous") switch (video_data.projection_type) { case "EQUIRECTANGULAR": diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index 1490916a..0d97d35a 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -25,7 +25,7 @@ <% end %> -<% if params.vr_mode %> +<% if !params.listen && params.vr_mode %> <% end %> -- cgit v1.2.3 From e655af251c4c738b3ec3c0497d165deb4c781017 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 16 Sep 2021 11:36:22 +0200 Subject: Try not to log search queries (#2362) --- src/invidious/helpers/logger.cr | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr index 5d91a258..e2e50905 100644 --- a/src/invidious/helpers/logger.cr +++ b/src/invidious/helpers/logger.cr @@ -17,7 +17,19 @@ class Invidious::LogHandler < Kemal::BaseLogHandler elapsed_time = Time.measure { call_next(context) } elapsed_text = elapsed_text(elapsed_time) - info("#{context.response.status_code} #{context.request.method} #{context.request.resource} #{elapsed_text}") + # Default: full path with parameters + requested_url = context.request.resource + + # Try not to log search queries passed as GET parameters during normal use + # (They will still be logged if log level is 'Debug' or 'Trace') + if @level > LogLevel::Debug && ( + requested_url.downcase.includes?("search") || requested_url.downcase.includes?("q=") + ) + # Log only the path + requested_url = context.request.path + end + + info("#{context.response.status_code} #{context.request.method} #{requested_url} #{elapsed_text}") context end -- cgit v1.2.3 From 262131f68e74d3de804b7804a7934ec8f237e75d Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 11 Sep 2021 22:18:45 -0700 Subject: Add script to resolve and fetch VideoJS files --- scripts/fetch-videojs-dependencies.cr | 153 ++++++++++++++++++++++ src/invidious/views/components/player_sources.ecr | 35 ++--- src/invidious/views/embed.ecr | 4 +- src/invidious/views/licenses.ecr | 20 +-- videojs-dependencies.yml | 45 +++++++ 5 files changed, 228 insertions(+), 29 deletions(-) create mode 100644 scripts/fetch-videojs-dependencies.cr create mode 100644 videojs-dependencies.yml (limited to 'src') diff --git a/scripts/fetch-videojs-dependencies.cr b/scripts/fetch-videojs-dependencies.cr new file mode 100644 index 00000000..cd94b214 --- /dev/null +++ b/scripts/fetch-videojs-dependencies.cr @@ -0,0 +1,153 @@ +require "http" +require "yaml" +require "digest/sha1" +require "option_parser" +require "colorize" + +# Taken from https://crystal-lang.org/api/1.1.1/OptionParser.html +minified = false +OptionParser.parse do |parser| + parser.banner = "Usage: Fetch VideoJS dependencies [arguments]" + parser.on("-m", "--minified", "Use minified versions of VideoJS dependencies (performance and bandwidth benefit)") { minified = true } + + parser.on("-h", "--help", "Show this help") do + puts parser + exit + end + + parser.invalid_option do |flag| + STDERR.puts "ERROR: #{flag} is not a valid option." + STDERR.puts parser + exit(1) + end +end + +required_dependencies = File.open("videojs-dependencies.yml") do |file| + YAML.parse(file).as_h +end + +def update_versions_yaml(required_dependencies, minified, dep_name) + File.open("assets/videojs/#{dep_name}/versions.yml", "w") do |io| + YAML.build(io) do |builder| + builder.mapping do + # Versions + builder.scalar "version" + builder.scalar "#{required_dependencies[dep_name]["version"]}" + + builder.scalar "minified" + builder.scalar minified + end + end + end +end + +# The first step is to check which dependencies we'll need to install. +# If the version we have requested in `videojs-dependencies.yml` is the +# same as what we've installed, we shouldn't do anything. Likewise, if it's +# different or the requested dependency just isn't present, then it needs to be +# installed. + +# Since we can't know when videojs-youtube-annotations is updated, we'll just always fetch +# a new copy each time. +dependencies_to_install = [] of String + +required_dependencies.keys.each do |dep| + dep = dep.to_s + path = "assets/videojs/#{dep}" + # Check for missing dependencies + if !Dir.exists?(path) + Dir.mkdir(path) + + update_versions_yaml(required_dependencies, minified, dep) + dependencies_to_install << dep + else + config = File.open("#{path}/versions.yml") do |file| + YAML.parse(file).as_h + end + + if config["version"].as_s != required_dependencies[dep]["version"].as_s || config["minified"].as_bool != minified + `rm -rf #{path}/*` + dependencies_to_install << dep + update_versions_yaml(required_dependencies, minified, dep) + end + end +end + +# Now we begin the fun part of installing the dependencies. +# But first we'll setup a temp directory to store the plugins +tmp_dir_path = "#{Dir.tempdir}/invidious-videojs-dep-install" +Dir.mkdir(tmp_dir_path) if !Dir.exists? tmp_dir_path + +channel = Channel(String).new + +dependencies_to_install.each do |dep| + spawn do + dep_name = dep + download_path = "#{tmp_dir_path}/#{dep}" + dest_path = "assets/videojs/#{dep}" + + HTTP::Client.get("https://registry.npmjs.org/#{dep}/-/#{dep}-#{required_dependencies[dep]["version"]}.tgz") do |response| + Dir.mkdir(download_path) + data = response.body_io.gets_to_end + File.write("#{download_path}/package.tgz", data) + + if Digest::SHA1.hexdigest(data) != required_dependencies[dep]["shasum"] + raise Exception.new("Checksum for '#{dep}' failed") + end + end + + # Unless we install an external dependency, crystal provides no way of extracting a tarball. + # Thus we'll go ahead and call a system command. + args = Process.parse_arguments("-zxvf '#{download_path}/package.tgz' -C '#{download_path}'") + process = Process.new("tar", args: args) + process.wait.success? # => true + + # Would use File.rename in the following steps but for some reason it just doesn't work here. + # Video.js itself is structured slightly differently + dep = "video" if dep == "video.js" + + # This dep nests everything under an additional JS or CSS folder + if dep == "silvermine-videojs-quality-selector" + js_path = "js/" + + # It also stores their quality selector as `quality-selector.css` + `mv #{download_path}/package/dist/css/quality-selector.css #{dest_path}/quality-selector.css` + else + js_path = "" + end + + # Would use File.rename but for some reason it just doesn't work here. + if minified && File.exists?("#{download_path}/package/dist/#{dep}.min.js") + `mv #{download_path}/package/dist/#{js_path}#{dep}.min.js #{dest_path}/#{dep}.js` + else + `mv #{download_path}/package/dist/#{js_path}#{dep}.js #{dest_path}/#{dep}.js` + end + + # Fetch CSS which isn't guaranteed to exist + # + # Also, video JS changes structure here once again... + dep = "video-js" if dep == "video" + + # VideoJS marker uses a dot on the CSS files. + dep = "videojs.markers" if dep == "videojs-markers" + + if File.exists?("#{download_path}/package/dist/#{dep}.css") + if minified && File.exists?("#{tmp_dir_path}/#{dep}/package/dist/#{dep}.min.css") + `mv #{download_path}/package/dist/#{dep}.min.css #{dest_path}/#{dep}.css` + else + `mv #{download_path}/package/dist/#{dep}.css #{dest_path}/#{dep}.css` + end + end + + channel.send(dep_name) + end +end + +puts "#{"Resolving".colorize(:green)} #{"VideoJS".colorize(:blue)} dependencies" +dependencies_to_install.size.times do + result = channel.receive + puts "#{"Fetched".colorize(:green)} #{result.colorize(:blue)}" +end + +# Cleanup +`rm -rf #{tmp_dir_path}` diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index 0d97d35a..6573b542 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -1,18 +1,19 @@ - - - - - - + + + + + + + - - - - - - - + + + + + + + <% if params.annotations %> @@ -21,11 +22,11 @@ <% end %> <% if params.listen || params.quality != "dash" %> - - + + <% end %> <% if !params.listen && params.vr_mode %> - - + + <% end %> diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index dbb86009..cd0fd0d5 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -6,8 +6,8 @@ <%= rendered "components/player_sources" %> - - + + <%= HTML.escape(video.title) %> - Invidious diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 9f5bcbdd..6d7f3842 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -67,7 +67,7 @@ - silvermine-videojs-quality-selector.min.js + silvermine-videojs-quality-selector @@ -123,7 +123,7 @@ - videojs-contrib-quality-levels.min.js + videojs-contrib-quality-levels.js @@ -137,7 +137,7 @@ - videojs-http-source-selector.min.js + videojs-http-source-selector.js @@ -151,7 +151,7 @@ - videojs-mobile-ui.min.js + videojs-mobile-ui.js @@ -165,7 +165,7 @@ - videojs-markers.min.js + videojs-markers.js @@ -179,7 +179,7 @@ - videojs-overlay.min.js + videojs/videojs-overlay/videojs-overlay.js @@ -193,7 +193,7 @@ - videojs-share.min.js + videojs-share.js @@ -207,7 +207,7 @@ - videojs-vtt-thumbnails.min.js + videojs-vtt-thumbnails.js @@ -235,7 +235,7 @@ - videojs-vr.js + videojs-vr.js @@ -249,7 +249,7 @@ - video.min.js + video.js diff --git a/videojs-dependencies.yml b/videojs-dependencies.yml new file mode 100644 index 00000000..c1625bfe --- /dev/null +++ b/videojs-dependencies.yml @@ -0,0 +1,45 @@ +video.js: + version: 7.14.3 + shasum: 0b612c09a0a81ef9bce65c710e73291cb06dc32c + +videojs-contrib-quality-levels: + version: 2.1.0 + shasum: 046e9e21ed01043f512b83a1916001d552457083 + +videojs-http-source-selector: + version: 1.1.6 + shasum: 073aadbea0106ba6c98d6b611094dbf8554ffa1f + +videojs-markers: + version: 1.0.1 + shasum: d7f8d804253fd587813271f8db308a22b9f7df34 + +videojs-mobile-ui: + version: 0.6.1 + shasum: 0e146c4c481cbee0729cb5e162e558b455562cd0 + +videojs-overlay: + version: 2.1.4 + shasum: 5a103b25374dbb753eb87960d8360c2e8f39cc05 + +videojs-share: + version: 3.2.1 + shasum: 0a3024b981387b9d21c058c829760a72c14b8ceb + +videojs-vr: + version: 1.8.0 + shasum: 7f2f07f760d8a329c615acd316e49da6ee8edd34 + +videojs-vtt-thumbnails: + version: 0.0.13 + shasum: d1e7d47f4ed80bb52f5fc4f4bad4bfc871f5970f + +silvermine-videojs-quality-selector: + version: 1.1.2 + shasum: 94033ff9ee52ba6da1263b97c9a74d5b3dfdf711 + +# videojs-youtube-annotations: +# github: https://github.com/afrmtbl/videojs-youtube-annotations + + + -- cgit v1.2.3 From 06a1d2ac41f4bd0992865eb10baefb6742222495 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 11 Sep 2021 22:47:12 -0700 Subject: Rename fetch_videojs* to fetch_player* --- scripts/fetch-player-dependencies.cr | 157 ++++++++++++++++++++++++++++++++++ scripts/fetch-videojs-dependencies.cr | 157 ---------------------------------- src/invidious.cr | 13 +++ 3 files changed, 170 insertions(+), 157 deletions(-) create mode 100644 scripts/fetch-player-dependencies.cr delete mode 100644 scripts/fetch-videojs-dependencies.cr (limited to 'src') diff --git a/scripts/fetch-player-dependencies.cr b/scripts/fetch-player-dependencies.cr new file mode 100644 index 00000000..ba9722f8 --- /dev/null +++ b/scripts/fetch-player-dependencies.cr @@ -0,0 +1,157 @@ +require "http" +require "yaml" +require "digest/sha1" +require "option_parser" +require "colorize" + +# Taken from https://crystal-lang.org/api/1.1.1/OptionParser.html +minified = false +OptionParser.parse do |parser| + parser.banner = "Usage: Fetch VideoJS dependencies [arguments]" + parser.on("-m", "--minified", "Use minified versions of VideoJS dependencies (performance and bandwidth benefit)") { minified = true } + + parser.on("-h", "--help", "Show this help") do + puts parser + exit + end + + parser.invalid_option do |flag| + STDERR.puts "ERROR: #{flag} is not a valid option." + STDERR.puts parser + exit(1) + end +end + +required_dependencies = File.open("videojs-dependencies.yml") do |file| + YAML.parse(file).as_h +end + +def update_versions_yaml(required_dependencies, minified, dep_name) + File.open("assets/videojs/#{dep_name}/versions.yml", "w") do |io| + YAML.build(io) do |builder| + builder.mapping do + # Versions + builder.scalar "version" + builder.scalar "#{required_dependencies[dep_name]["version"]}" + + builder.scalar "minified" + builder.scalar minified + end + end + end +end + +# The first step is to check which dependencies we'll need to install. +# If the version we have requested in `videojs-dependencies.yml` is the +# same as what we've installed, we shouldn't do anything. Likewise, if it's +# different or the requested dependency just isn't present, then it needs to be +# installed. + +# Since we can't know when videojs-youtube-annotations is updated, we'll just always fetch +# a new copy each time. +dependencies_to_install = [] of String + +required_dependencies.keys.each do |dep| + dep = dep.to_s + path = "assets/videojs/#{dep}" + # Check for missing dependencies + if !Dir.exists?(path) + Dir.mkdir(path) + + update_versions_yaml(required_dependencies, minified, dep) + dependencies_to_install << dep + else + config = File.open("#{path}/versions.yml") do |file| + YAML.parse(file).as_h + end + + if config["version"].as_s != required_dependencies[dep]["version"].as_s || config["minified"].as_bool != minified + `rm -rf #{path}/*` + dependencies_to_install << dep + update_versions_yaml(required_dependencies, minified, dep) + end + end +end + +# Now we begin the fun part of installing the dependencies. +# But first we'll setup a temp directory to store the plugins +tmp_dir_path = "#{Dir.tempdir}/invidious-videojs-dep-install" +Dir.mkdir(tmp_dir_path) if !Dir.exists? tmp_dir_path + +channel = Channel(String).new + +dependencies_to_install.each do |dep| + spawn do + dep_name = dep + download_path = "#{tmp_dir_path}/#{dep}" + dest_path = "assets/videojs/#{dep}" + + HTTP::Client.get("https://registry.npmjs.org/#{dep}/-/#{dep}-#{required_dependencies[dep]["version"]}.tgz") do |response| + Dir.mkdir(download_path) + data = response.body_io.gets_to_end + File.write("#{download_path}/package.tgz", data) + + if Digest::SHA1.hexdigest(data) != required_dependencies[dep]["shasum"] + raise Exception.new("Checksum for '#{dep}' failed") + end + end + + # Unless we install an external dependency, crystal provides no way of extracting a tarball. + # Thus we'll go ahead and call a system command. + args = Process.parse_arguments("-zxvf '#{download_path}/package.tgz' -C '#{download_path}'") + process = Process.new("tar", args: args) + process.wait.success? # => true + + # Would use File.rename in the following steps but for some reason it just doesn't work here. + # Video.js itself is structured slightly differently + dep = "video" if dep == "video.js" + + # This dep nests everything under an additional JS or CSS folder + if dep == "silvermine-videojs-quality-selector" + js_path = "js/" + + # It also stores their quality selector as `quality-selector.css` + `mv #{download_path}/package/dist/css/quality-selector.css #{dest_path}/quality-selector.css` + else + js_path = "" + end + + # Would use File.rename but for some reason it just doesn't work here. + if minified && File.exists?("#{download_path}/package/dist/#{dep}.min.js") + `mv #{download_path}/package/dist/#{js_path}#{dep}.min.js #{dest_path}/#{dep}.js` + else + `mv #{download_path}/package/dist/#{js_path}#{dep}.js #{dest_path}/#{dep}.js` + end + + # Fetch CSS which isn't guaranteed to exist + # + # Also, video JS changes structure here once again... + dep = "video-js" if dep == "video" + + # VideoJS marker uses a dot on the CSS files. + dep = "videojs.markers" if dep == "videojs-markers" + + if File.exists?("#{download_path}/package/dist/#{dep}.css") + if minified && File.exists?("#{tmp_dir_path}/#{dep}/package/dist/#{dep}.min.css") + `mv #{download_path}/package/dist/#{dep}.min.css #{dest_path}/#{dep}.css` + else + `mv #{download_path}/package/dist/#{dep}.css #{dest_path}/#{dep}.css` + end + end + + channel.send(dep_name) + end +end + +if dependencies_to_install.empty? + puts "#{"VideoJS".colorize(:blue)} #{"dependencies".colorize(:green)} are satisfied" +else + puts "#{"Resolving".colorize(:green)} #{"VideoJS".colorize(:blue)} dependencies" + dependencies_to_install.size.times do + result = channel.receive + puts "#{"Fetched".colorize(:green)} #{result.colorize(:blue)}" + end +end + +# Cleanup +`rm -rf #{tmp_dir_path}` diff --git a/scripts/fetch-videojs-dependencies.cr b/scripts/fetch-videojs-dependencies.cr deleted file mode 100644 index ba9722f8..00000000 --- a/scripts/fetch-videojs-dependencies.cr +++ /dev/null @@ -1,157 +0,0 @@ -require "http" -require "yaml" -require "digest/sha1" -require "option_parser" -require "colorize" - -# Taken from https://crystal-lang.org/api/1.1.1/OptionParser.html -minified = false -OptionParser.parse do |parser| - parser.banner = "Usage: Fetch VideoJS dependencies [arguments]" - parser.on("-m", "--minified", "Use minified versions of VideoJS dependencies (performance and bandwidth benefit)") { minified = true } - - parser.on("-h", "--help", "Show this help") do - puts parser - exit - end - - parser.invalid_option do |flag| - STDERR.puts "ERROR: #{flag} is not a valid option." - STDERR.puts parser - exit(1) - end -end - -required_dependencies = File.open("videojs-dependencies.yml") do |file| - YAML.parse(file).as_h -end - -def update_versions_yaml(required_dependencies, minified, dep_name) - File.open("assets/videojs/#{dep_name}/versions.yml", "w") do |io| - YAML.build(io) do |builder| - builder.mapping do - # Versions - builder.scalar "version" - builder.scalar "#{required_dependencies[dep_name]["version"]}" - - builder.scalar "minified" - builder.scalar minified - end - end - end -end - -# The first step is to check which dependencies we'll need to install. -# If the version we have requested in `videojs-dependencies.yml` is the -# same as what we've installed, we shouldn't do anything. Likewise, if it's -# different or the requested dependency just isn't present, then it needs to be -# installed. - -# Since we can't know when videojs-youtube-annotations is updated, we'll just always fetch -# a new copy each time. -dependencies_to_install = [] of String - -required_dependencies.keys.each do |dep| - dep = dep.to_s - path = "assets/videojs/#{dep}" - # Check for missing dependencies - if !Dir.exists?(path) - Dir.mkdir(path) - - update_versions_yaml(required_dependencies, minified, dep) - dependencies_to_install << dep - else - config = File.open("#{path}/versions.yml") do |file| - YAML.parse(file).as_h - end - - if config["version"].as_s != required_dependencies[dep]["version"].as_s || config["minified"].as_bool != minified - `rm -rf #{path}/*` - dependencies_to_install << dep - update_versions_yaml(required_dependencies, minified, dep) - end - end -end - -# Now we begin the fun part of installing the dependencies. -# But first we'll setup a temp directory to store the plugins -tmp_dir_path = "#{Dir.tempdir}/invidious-videojs-dep-install" -Dir.mkdir(tmp_dir_path) if !Dir.exists? tmp_dir_path - -channel = Channel(String).new - -dependencies_to_install.each do |dep| - spawn do - dep_name = dep - download_path = "#{tmp_dir_path}/#{dep}" - dest_path = "assets/videojs/#{dep}" - - HTTP::Client.get("https://registry.npmjs.org/#{dep}/-/#{dep}-#{required_dependencies[dep]["version"]}.tgz") do |response| - Dir.mkdir(download_path) - data = response.body_io.gets_to_end - File.write("#{download_path}/package.tgz", data) - - if Digest::SHA1.hexdigest(data) != required_dependencies[dep]["shasum"] - raise Exception.new("Checksum for '#{dep}' failed") - end - end - - # Unless we install an external dependency, crystal provides no way of extracting a tarball. - # Thus we'll go ahead and call a system command. - args = Process.parse_arguments("-zxvf '#{download_path}/package.tgz' -C '#{download_path}'") - process = Process.new("tar", args: args) - process.wait.success? # => true - - # Would use File.rename in the following steps but for some reason it just doesn't work here. - # Video.js itself is structured slightly differently - dep = "video" if dep == "video.js" - - # This dep nests everything under an additional JS or CSS folder - if dep == "silvermine-videojs-quality-selector" - js_path = "js/" - - # It also stores their quality selector as `quality-selector.css` - `mv #{download_path}/package/dist/css/quality-selector.css #{dest_path}/quality-selector.css` - else - js_path = "" - end - - # Would use File.rename but for some reason it just doesn't work here. - if minified && File.exists?("#{download_path}/package/dist/#{dep}.min.js") - `mv #{download_path}/package/dist/#{js_path}#{dep}.min.js #{dest_path}/#{dep}.js` - else - `mv #{download_path}/package/dist/#{js_path}#{dep}.js #{dest_path}/#{dep}.js` - end - - # Fetch CSS which isn't guaranteed to exist - # - # Also, video JS changes structure here once again... - dep = "video-js" if dep == "video" - - # VideoJS marker uses a dot on the CSS files. - dep = "videojs.markers" if dep == "videojs-markers" - - if File.exists?("#{download_path}/package/dist/#{dep}.css") - if minified && File.exists?("#{tmp_dir_path}/#{dep}/package/dist/#{dep}.min.css") - `mv #{download_path}/package/dist/#{dep}.min.css #{dest_path}/#{dep}.css` - else - `mv #{download_path}/package/dist/#{dep}.css #{dest_path}/#{dep}.css` - end - end - - channel.send(dep_name) - end -end - -if dependencies_to_install.empty? - puts "#{"VideoJS".colorize(:blue)} #{"dependencies".colorize(:green)} are satisfied" -else - puts "#{"Resolving".colorize(:green)} #{"VideoJS".colorize(:blue)} dependencies" - dependencies_to_install.size.times do - result = channel.receive - puts "#{"Fetched".colorize(:green)} #{result.colorize(:blue)}" - end -end - -# Cleanup -`rm -rf #{tmp_dir_path}` diff --git a/src/invidious.cr b/src/invidious.cr index 5ad2dd91..138e1f50 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -126,6 +126,19 @@ if CONFIG.check_tables end end +# Resolve player dependencies. This is done at compile time. +# +# Running the script by itself would show some colorful feedback while this doesn't. +# Perhaps we should just move the script to runtime in order to get that feedback? + +{% puts "\nChecking player dependencies...\n" %} +{% if flag?(:minified_player_dependencies) %} + {% run("../scripts/fetch-player-dependencies.cr", "--minified") %} +{% else %} + {% run("../scripts/fetch-player-dependencies.cr") %} +{% end %} +{% puts "Done!\n" %} + # Start jobs if CONFIG.channel_threads > 0 -- cgit v1.2.3 From 2451497b3161a94299f2be08ddf805e6e0dd24d6 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 11 Sep 2021 22:57:29 -0700 Subject: Typo --- src/invidious/views/licenses.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 6d7f3842..c699dfab 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -179,7 +179,7 @@ - videojs/videojs-overlay/videojs-overlay.js + videojs-overlay.js -- cgit v1.2.3 From 3a6085ad31eaf1a94946ccdc4eaf66e76165e90b Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 11 Sep 2021 23:44:12 -0700 Subject: Readd player.css --- src/invidious/views/components/player_sources.ecr | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index 6573b542..59f98003 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -3,8 +3,7 @@ - - + -- cgit v1.2.3 From 62c1991b887fa397ece6735219327aa9448759eb Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 11 Sep 2021 23:48:17 -0700 Subject: Typo --- src/invidious/views/components/player_sources.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index 59f98003..da2ab54c 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -22,7 +22,7 @@ <% if params.listen || params.quality != "dash" %> - + <% end %> <% if !params.listen && params.vr_mode %> -- cgit v1.2.3 From 0323202a0313ed69e8df00ee077edeaea61109a3 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 12 Sep 2021 00:10:22 -0700 Subject: Revert to iv-org fork of silvermine-videojs-quality-selector Upstream requires at least two additional sources. Whereas Invidious needs it to be able to display a single additional source for normal (dashless) qualites. Aka medium and hd720. --- assets/css/quality-selector.css | 1 + assets/js/silvermine-videojs-quality-selector.min.js | 4 ++++ src/invidious/views/components/player_sources.ecr | 4 ++-- src/invidious/views/licenses.ecr | 4 ++-- videojs-dependencies.yml | 15 ++++++++++++--- 5 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 assets/css/quality-selector.css create mode 100644 assets/js/silvermine-videojs-quality-selector.min.js (limited to 'src') diff --git a/assets/css/quality-selector.css b/assets/css/quality-selector.css new file mode 100644 index 00000000..f3cc0334 --- /dev/null +++ b/assets/css/quality-selector.css @@ -0,0 +1 @@ +.vjs-quality-selector .vjs-menu-button{margin:0;padding:0;height:100%;width:100%}.vjs-quality-selector .vjs-icon-placeholder{font-family:'VideoJS';font-weight:normal;font-style:normal}.vjs-quality-selector .vjs-icon-placeholder:before{content:'\f110'}.vjs-quality-changing .vjs-big-play-button{display:none}.vjs-quality-changing .vjs-control-bar{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;visibility:visible;opacity:1} diff --git a/assets/js/silvermine-videojs-quality-selector.min.js b/assets/js/silvermine-videojs-quality-selector.min.js new file mode 100644 index 00000000..88621e8d --- /dev/null +++ b/assets/js/silvermine-videojs-quality-selector.min.js @@ -0,0 +1,4 @@ +/*! @silvermine/videojs-quality-selector 2020-03-02 v1.1.2-36-g64d620a-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":">",'"':""","'":"'","`":"`"},W=h.invert(P);h.escape=D(P),h.unescape=D(W),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 Y(n){return"\\"+K[n]}var z=/(.)^/,K={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},G=/\\|'|\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||z).source,(n.interpolate||z).source,(n.evaluate||z).source].join("|")+"|$","g"),o=0,c="__p+='";u.replace(r,function(n,e,t,r,i){return c+=u.slice(o,i).replace(G,Y),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 H(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),H(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],H(this,n)}}),h.each(["concat","join","slice"],function(n){var e=r[n];h.prototype[n]=function(){return H(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.PLAYER_SOURCES_CHANGED,function(){this.update()}.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",PLAYER_SOURCES_CHANGED:"playerSourcesChanged"}},{}],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(),u.isEqual(r,i._qualitySelectorPreviousSources)||(i.trigger(o.PLAYER_SOURCES_CHANGED,r),i._qualitySelectorPreviousSources=r),t=u.find(r,function(n){return!0===n.selected||"true"===n.selected||"selected"===n.selected})||n,i.trigger(o.QUALITY_SELECTED,t),e(null,t)}}})}},{"../events":5,underscore:2}],8:[function(n,e,t){"use strict";n("./index")()},{"./index":6}],9:[function(n,e,t){"use strict";var r=n("class.extend");e.exports=r.extend({init:function(n,e){this._player=n,this._seekToTime=e,this._hasFinished=!1,this._keepThisInstanceWhenPlayerSourcesChange=!1,this._seekWhenSafe()},_seekWhenSafe:function(){this._player.readyState()<3?(this._seekFn=this._seek.bind(this),this._player.one("canplay",this._seekFn)):this._seek()},onPlayerSourcesChange:function(){this._keepThisInstanceWhenPlayerSourcesChange?this._keepThisInstanceWhenPlayerSourcesChange=!1:this.cancel()},onQualitySelectionChange:function(){this.hasFinished()||(this._keepThisInstanceWhenPlayerSourcesChange=!0)},_seek:function(){this._player.currentTime(this._seekToTime),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0},hasFinished:function(){return this._hasFinished},cancel:function(){this._player.off("canplay",this._seekFn),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0}})},{"class.extend":1}]},{},[8]); +//# sourceMappingURL=silvermine-videojs-quality-selector.min.js.map \ No newline at end of file diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index da2ab54c..9af3899c 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -21,8 +21,8 @@ <% end %> <% if params.listen || params.quality != "dash" %> - - + + <% end %> <% if !params.listen && params.vr_mode %> diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index c699dfab..63300f85 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -67,7 +67,7 @@ - silvermine-videojs-quality-selector + silvermine-videojs-quality-selector.min.js @@ -75,7 +75,7 @@ - <%= translate(locale, "source") %> + <%= translate(locale, "source") %> diff --git a/videojs-dependencies.yml b/videojs-dependencies.yml index c1625bfe..1ee00b1f 100644 --- a/videojs-dependencies.yml +++ b/videojs-dependencies.yml @@ -34,9 +34,18 @@ videojs-vtt-thumbnails: version: 0.0.13 shasum: d1e7d47f4ed80bb52f5fc4f4bad4bfc871f5970f -silvermine-videojs-quality-selector: - version: 1.1.2 - shasum: 94033ff9ee52ba6da1263b97c9a74d5b3dfdf711 +# We're using iv-org's fork of videojs-quality-selector, +# which isn't published on NPM, and doesn't have any +# easy way of fetching the compiled variant. +# + +# silvermine-videojs-quality-selector: +# version: 1.1.2 +# shasum: 94033ff9ee52ba6da1263b97c9a74d5b3dfdf711 + + +# Ditto. Although, this one does allow us to use git to fetch the compiled version. +# However, it lacks versioning. # videojs-youtube-annotations: # github: https://github.com/afrmtbl/videojs-youtube-annotations -- cgit v1.2.3 From 02431b3f982c4dc4a385566718a57abec47d5950 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 12 Sep 2021 00:14:22 -0700 Subject: Use correct videojs-vtt-thumbnails location in licences.ecr --- src/invidious/views/licenses.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 63300f85..861913d0 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -215,7 +215,7 @@ - <%= translate(locale, "source") %> + <%= translate(locale, "source") %> -- cgit v1.2.3 From 4a0359c04e6cfa38f325f34306f503946940f4a6 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 18 Sep 2021 14:48:57 -0700 Subject: Pass success msg from dep script during compile-time --- src/invidious.cr | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 138e1f50..e29b73a8 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -133,11 +133,10 @@ end {% puts "\nChecking player dependencies...\n" %} {% if flag?(:minified_player_dependencies) %} - {% run("../scripts/fetch-player-dependencies.cr", "--minified") %} + {% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %} {% else %} - {% run("../scripts/fetch-player-dependencies.cr") %} + {% puts run("../scripts/fetch-player-dependencies.cr").stringify %} {% end %} -{% puts "Done!\n" %} # Start jobs -- cgit v1.2.3 From 87f46a7532dbb8abdb09413bcc2a62f63e0d4c9e Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Sun, 19 Sep 2021 10:30:19 +0000 Subject: Unregister captcha job (#2390) --- src/invidious.cr | 4 ---- 1 file changed, 4 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 5ad2dd91..fa585435 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -153,10 +153,6 @@ if CONFIG.popular_enabled Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) end -if CONFIG.captcha_key - Invidious::Jobs.register Invidious::Jobs::BypassCaptchaJob.new -end - connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32) Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, CONFIG.database_url) -- cgit v1.2.3 From 2b0bb69a4fe6a0796d7365365b6a7b80126afc95 Mon Sep 17 00:00:00 2001 From: Walkyst <41806921+Walkyst@users.noreply.github.com> Date: Tue, 21 Sep 2021 08:39:32 +0300 Subject: Fix mixes route (#2421) --- src/invidious/routing.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index e0cddeb5..7551f22d 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -73,7 +73,7 @@ macro define_v1_api_routes Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist Invidious::Routing.get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist - Invidious::Routing.get "/api/v1//mixes/:rdid", {{namespace}}::Misc, :mixes + Invidious::Routing.get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes end macro define_api_manifest_routes -- cgit v1.2.3 From 86ca568d6dbc505d6c1e977bb206b529597f2955 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Thu, 23 Sep 2021 06:44:26 +0000 Subject: Remove login type button from frontend (#2423) --- src/invidious/views/login.ecr | 15 --------------- 1 file changed, 15 deletions(-) (limited to 'src') diff --git a/src/invidious/views/login.ecr b/src/invidious/views/login.ecr index 1f6618e8..e2963e9f 100644 --- a/src/invidious/views/login.ecr +++ b/src/invidious/views/login.ecr @@ -6,21 +6,6 @@
- - -
- <% case account_type when %> <% when "google" %>
-- cgit v1.2.3 From 6d68fbc31d54e2561e00e88ab5006d4ed4da26d7 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Sat, 25 Sep 2021 02:47:52 +0000 Subject: Fix livestream regex regression caused by #2271 Closes #2352 Special thanks to @WaywardHeart for finding this issue! --- src/invidious/routes/api/manifest.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index 93bee55c..f8963587 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -160,7 +160,7 @@ module Invidious::Routes::API::Manifest manifest = response.body if local - manifest = manifest.gsub(/^https:\/\/r\d---.{11}\.c\.youtube\.com[^\n]*/m) do |match| + manifest = manifest.gsub(/^https:\/\/\w+---.{11}\.c\.youtube\.com[^\n]*/m) do |match| path = URI.parse(match).path path = path.lchop("/videoplayback/") -- cgit v1.2.3 From 1323b94b7a3a90a27a4353edddb7b9c103044e02 Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 4 May 2021 01:48:51 -0700 Subject: Rewrite extract_item and extract_items functions This commit completely rewrites the extract_item and extract_items function. Before this commit these two function were an unreadable mess. The extract_item function was a lengthy if-elsif chain while the extract_items function contained an incomprehensible mess of .try, else and ||. With this commit both of these functions have been pulled into a separate file with the internal logic being moved to a few classes. This significantly reduces the size of these two methods, enhances readability and makes adding new extraction/parse rules much simpler. See diff for details. -- This cherry-picked commit also removes the code for parsing featured channels present on the original. (cherry picked from commit a027fbf7af1f96dc26fe5a610525ae52bcc40c28) --- src/invidious/helpers/extractors.cr | 317 ++++++++++++++++++++++++++++++++++++ src/invidious/helpers/helpers.cr | 162 +----------------- 2 files changed, 320 insertions(+), 159 deletions(-) create mode 100644 src/invidious/helpers/extractors.cr (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr new file mode 100644 index 00000000..e8daa913 --- /dev/null +++ b/src/invidious/helpers/extractors.cr @@ -0,0 +1,317 @@ +# This file contains helper methods to parse the Youtube API json data into +# neat little packages we can use + +# Tuple of Parsers/Extractors so we can easily cycle through them. +private ITEM_CONTAINER_EXTRACTOR = { + YoutubeTabsExtractor.new, + SearchResultsExtractor.new, + ContinuationExtractor.new, +} + +private ITEM_PARSERS = { + VideoParser.new, + ChannelParser.new, + GridPlaylistParser.new, + PlaylistParser.new, +} + +private struct AuthorFallback + property name, id + + def initialize(@name : String? = nil, @id : String? = nil) + end +end + +# The following are the parsers for parsing raw item data into neatly packaged structs. +# They're accessed through the process() method which validates the given data as applicable +# to their specific struct and then use the internal parse() method to assemble the struct +# specific to their category. +private class ItemParser + # Base type for all item parsers. + def process(item : JSON::Any, author_fallback : AuthorFallback) + end + + private def parse(item_contents : JSON::Any, author_fallback : AuthorFallback) + end +end + +private class VideoParser < ItemParser + def process(item, author_fallback) + if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?) + return self.parse(item_contents, author_fallback) + end + end + + private def parse(item_contents, author_fallback) + video_id = item_contents["videoId"].as_s + title = item_contents["title"].try { |t| t["simpleText"]?.try &.as_s || t["runs"]?.try &.as_a.map(&.["text"].as_s).join("") } || "" + + author_info = item_contents["ownerText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? + author = author_info.try &.["text"].as_s || author_fallback.name || "" + author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_fallback.id || "" + + published = item_contents["publishedTimeText"]?.try &.["simpleText"]?.try { |t| decode_date(t.as_s) } || Time.local + view_count = item_contents["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 + description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" + length_seconds = item_contents["lengthText"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || + item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?).try &.["thumbnailOverlayTimeStatusRenderer"]? + .try &.["text"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || 0 + + live_now = false + paid = false + premium = false + + premiere_timestamp = item_contents["upcomingEventData"]?.try &.["startTime"]?.try { |t| Time.unix(t.as_s.to_i64) } + + item_contents["badges"]?.try &.as_a.each do |badge| + b = badge["metadataBadgeRenderer"] + case b["label"].as_s + when "LIVE NOW" + live_now = true + when "New", "4K", "CC" + # TODO + when "Premium" + # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] + premium = true + else nil # Ignore + end + end + + SearchVideo.new({ + title: title, + id: video_id, + author: author, + ucid: author_id, + published: published, + views: view_count, + description_html: description_html, + length_seconds: length_seconds, + live_now: live_now, + premium: premium, + premiere_timestamp: premiere_timestamp, + }) + end +end + +private class ChannelParser < ItemParser + def process(item, author_fallback) + if item_contents = item["channelRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def parse(item_contents, author_fallback) + author = item_contents["title"]["simpleText"]?.try &.as_s || author_fallback.name || "" + author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id || "" + + author_thumbnail = item_contents["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try &.["url"]?.try &.as_s || "" + subscriber_count = item_contents["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0 + + auto_generated = false + auto_generated = true if !item_contents["videoCountText"]? + video_count = item_contents["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 + description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" + + SearchChannel.new({ + author: author, + ucid: author_id, + author_thumbnail: author_thumbnail, + subscriber_count: subscriber_count, + video_count: video_count, + description_html: description_html, + auto_generated: auto_generated, + }) + end +end + +private class GridPlaylistParser < ItemParser + def process(item, author_fallback) + if item_contents = item["gridPlaylistRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def parse(item_contents, author_fallback) + title = item_contents["title"]["runs"].as_a[0]?.try &.["text"].as_s || "" + plid = item_contents["playlistId"]?.try &.as_s || "" + + video_count = item_contents["videoCountText"]["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 + playlist_thumbnail = item_contents["thumbnail"]["thumbnails"][0]?.try &.["url"]?.try &.as_s || "" + + SearchPlaylist.new({ + title: title, + id: plid, + author: author_fallback.name || "", + ucid: author_fallback.id || "", + video_count: video_count, + videos: [] of SearchPlaylistVideo, + thumbnail: playlist_thumbnail, + }) + end +end + +private class PlaylistParser < ItemParser + def process(item, author_fallback) + if item_contents = item["playlistRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + def parse(item_contents, author_fallback) + title = item_contents["title"]["simpleText"]?.try &.as_s || "" + plid = item_contents["playlistId"]?.try &.as_s || "" + + video_count = item_contents["videoCount"]?.try &.as_s.to_i || 0 + playlist_thumbnail = item_contents["thumbnails"].as_a[0]?.try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"].as_s || "" + + author_info = item_contents["shortBylineText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? + author = author_info.try &.["text"].as_s || author_fallback.name || "" + author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_fallback.id || "" + + videos = item_contents["videos"]?.try &.as_a.map do |v| + v = v["childVideoRenderer"] + v_title = v["title"]["simpleText"]?.try &.as_s || "" + v_id = v["videoId"]?.try &.as_s || "" + v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0 + SearchPlaylistVideo.new({ + title: v_title, + id: v_id, + length_seconds: v_length_seconds, + }) + end || [] of SearchPlaylistVideo + + # TODO: item_contents["publishedTimeText"]? + + SearchPlaylist.new({ + title: title, + id: plid, + author: author, + ucid: author_id, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail, + }) + end +end + +# The following are the extractors for extracting an array of items from +# the internal Youtube API's JSON response. The result is then packaged into +# a structure we can more easily use via the parsers above. Their internals are +# identical to the item parsers. + +private class ItemsContainerExtractor + def process(item : Hash(String, JSON::Any)) + end + + private def extract(target : JSON::Any) + end +end + +private class YoutubeTabsExtractor < ItemsContainerExtractor + def process(initial_data) + if target = initial_data["twoColumnBrowseResultsRenderer"]? + self.extract(target) + end + end + + private def extract(target) + raw_items = [] of JSON::Any + selected_tab = extract_selected_tab(target["tabs"]) + content = selected_tab["tabRenderer"]["content"] + + content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| + renderer_container = renderer_container["itemSectionRenderer"] + renderer_container_contents = renderer_container["contents"].as_a[0] + + # Shelf renderer usually refer to a category and would need special handling once + # An extractor for categories are added. But for now it is just used to + # extract items for the trending page + if items_container = renderer_container_contents["shelfRenderer"]? + if items_container["content"]["expandedShelfContentsRenderer"]? + items_container = items_container["content"]["expandedShelfContentsRenderer"] + end + elsif items_container = renderer_container_contents["gridRenderer"]? + else + items_container = renderer_container_contents + end + + items_container["items"].as_a.each do |item| + raw_items << item + end + end + + return raw_items + end +end + +private class SearchResultsExtractor < ItemsContainerExtractor + def process(initial_data) + if target = initial_data["twoColumnSearchResultsRenderer"]? + self.extract(target) + end + end + + private def extract(target) + raw_items = [] of JSON::Any + content = target["primaryContents"] + renderer = content["sectionListRenderer"]["contents"].as_a[0]["itemSectionRenderer"] + raw_items = renderer["contents"].as_a + + return raw_items + end +end + +private class ContinuationExtractor < ItemsContainerExtractor + def process(initial_data) + if target = initial_data["continuationContents"]? + self.extract(target) + end + end + + private def extract(target) + raw_items = [] of JSON::Any + if content = target["gridContinuation"]? + raw_items = content["items"].as_a + end + + return raw_items + end +end + +def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fallback : String? = nil) + # Parses an item from Youtube's JSON response into a more usable structure. + # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. + author_fallback = AuthorFallback.new(author_fallback, author_id_fallback) + + # Cycles through all of the item parsers and attempt to parse the raw YT JSON data. + # Each parser automatically validates the data given to see if the data is + # applicable to itself. If not nil is returned and the next parser is attemped. + ITEM_PARSERS.each do |parser| + result = parser.process(item, author_fallback) + if !result.nil? + return result + end + end + # TODO radioRenderer, showRenderer, shelfRenderer, horizontalCardListRenderer, searchPyvRenderer +end + +def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) + items = [] of SearchItem + initial_data = initial_data["contents"]?.try &.as_h || initial_data["response"]?.try &.as_h || initial_data + + # This is identicial to the parser cyling of extract_item(). + ITEM_CONTAINER_EXTRACTOR.each do |extractor| + results = extractor.process(initial_data) + if !results.nil? + results.each do |item| + parsed_result = extract_item(item, author_fallback, author_id_fallback) + + if !parsed_result.nil? + items << parsed_result + end + end + end + end + + return items +end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index fb33df1c..1a058195 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -251,165 +251,9 @@ def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : Str extract_items(initial_data, author_fallback, author_id_fallback).select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) end -def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fallback : String? = nil) - if i = (item["videoRenderer"]? || item["gridVideoRenderer"]?) - video_id = i["videoId"].as_s - title = i["title"].try { |t| t["simpleText"]?.try &.as_s || t["runs"]?.try &.as_a.map(&.["text"].as_s).join("") } || "" - - author_info = i["ownerText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? - author = author_info.try &.["text"].as_s || author_fallback || "" - author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_id_fallback || "" - - published = i["publishedTimeText"]?.try &.["simpleText"]?.try { |t| decode_date(t.as_s) } || Time.local - view_count = i["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 - description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || "" - length_seconds = i["lengthText"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || - i["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?).try &.["thumbnailOverlayTimeStatusRenderer"]? - .try &.["text"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || 0 - - live_now = false - premium = false - - premiere_timestamp = i["upcomingEventData"]?.try &.["startTime"]?.try { |t| Time.unix(t.as_s.to_i64) } - - i["badges"]?.try &.as_a.each do |badge| - b = badge["metadataBadgeRenderer"] - case b["label"].as_s - when "LIVE NOW" - live_now = true - when "New", "4K", "CC" - # TODO - when "Premium" - # TODO: Potentially available as i["topStandaloneBadge"]["metadataBadgeRenderer"] - premium = true - else nil # Ignore - end - end - - SearchVideo.new({ - title: title, - id: video_id, - author: author, - ucid: author_id, - published: published, - views: view_count, - description_html: description_html, - length_seconds: length_seconds, - live_now: live_now, - premium: premium, - premiere_timestamp: premiere_timestamp, - }) - elsif i = item["channelRenderer"]? - author = i["title"]["simpleText"]?.try &.as_s || author_fallback || "" - author_id = i["channelId"]?.try &.as_s || author_id_fallback || "" - - author_thumbnail = i["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try &.["url"]?.try &.as_s || "" - subscriber_count = i["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0 - - auto_generated = false - auto_generated = true if !i["videoCountText"]? - video_count = i["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 - description_html = i["descriptionSnippet"]?.try { |t| parse_content(t) } || "" - - SearchChannel.new({ - author: author, - ucid: author_id, - author_thumbnail: author_thumbnail, - subscriber_count: subscriber_count, - video_count: video_count, - description_html: description_html, - auto_generated: auto_generated, - }) - elsif i = item["gridPlaylistRenderer"]? - title = i["title"]["runs"].as_a[0]?.try &.["text"].as_s || "" - plid = i["playlistId"]?.try &.as_s || "" - - video_count = i["videoCountText"]["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 - playlist_thumbnail = i["thumbnail"]["thumbnails"][0]?.try &.["url"]?.try &.as_s || "" - - SearchPlaylist.new({ - title: title, - id: plid, - author: author_fallback || "", - ucid: author_id_fallback || "", - video_count: video_count, - videos: [] of SearchPlaylistVideo, - thumbnail: playlist_thumbnail, - }) - elsif i = item["playlistRenderer"]? - title = i["title"]["simpleText"]?.try &.as_s || "" - plid = i["playlistId"]?.try &.as_s || "" - - video_count = i["videoCount"]?.try &.as_s.to_i || 0 - playlist_thumbnail = i["thumbnails"].as_a[0]?.try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"].as_s || "" - - author_info = i["shortBylineText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? - author = author_info.try &.["text"].as_s || author_fallback || "" - author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_id_fallback || "" - - videos = i["videos"]?.try &.as_a.map do |v| - v = v["childVideoRenderer"] - v_title = v["title"]["simpleText"]?.try &.as_s || "" - v_id = v["videoId"]?.try &.as_s || "" - v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0 - SearchPlaylistVideo.new({ - title: v_title, - id: v_id, - length_seconds: v_length_seconds, - }) - end || [] of SearchPlaylistVideo - - # TODO: i["publishedTimeText"]? - - SearchPlaylist.new({ - title: title, - id: plid, - author: author, - ucid: author_id, - video_count: video_count, - videos: videos, - thumbnail: playlist_thumbnail, - }) - elsif i = item["radioRenderer"]? # Mix - # TODO - elsif i = item["showRenderer"]? # Show - # TODO - elsif i = item["shelfRenderer"]? - elsif i = item["horizontalCardListRenderer"]? - elsif i = item["searchPyvRenderer"]? # Ad - end -end - -def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) - items = [] of SearchItem - - channel_v2_response = initial_data - .try &.["continuationContents"]? - .try &.["gridContinuation"]? - .try &.["items"]? - - if channel_v2_response - channel_v2_response.try &.as_a.each { |item| - extract_item(item, author_fallback, author_id_fallback) - .try { |t| items << t } - } - else - initial_data.try { |t| t["contents"]? || t["response"]? } - .try { |t| t["twoColumnBrowseResultsRenderer"]?.try &.["tabs"].as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]?.try &.["tabRenderer"]["content"] || - t["twoColumnSearchResultsRenderer"]?.try &.["primaryContents"] || - t["continuationContents"]? } - .try { |t| t["sectionListRenderer"]? || t["sectionListContinuation"]? } - .try &.["contents"].as_a - .each { |c| c.try &.["itemSectionRenderer"]?.try &.["contents"].as_a - .try { |t| t[0]?.try &.["shelfRenderer"]?.try &.["content"]["expandedShelfContentsRenderer"]?.try &.["items"].as_a || - t[0]?.try &.["gridRenderer"]?.try &.["items"].as_a || t } - .each { |item| - extract_item(item, author_fallback, author_id_fallback) - .try { |t| items << t } - } } - end - - items +def extract_selected_tab(tabs) + # Extract the selected tab from the array of tabs Youtube returns + return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0] end def check_enum(db, enum_name, struct_type = nil) -- cgit v1.2.3 From a50f64f6e9ab55efa9301915817b11f152625f22 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 7 May 2021 05:13:53 -0700 Subject: Add parser for categories (shelfRenderer) This commit adds a new parser for YT's shelfRenderers which are typically used to denote different categories.The code for featured channels parsing has also been moved to use the new parser but some additional refactoring are needed there. The ContinuationExtractor has also been improved and is now capable of extraction continuation data that is packaged under "appendContinuationItemsAction" In additional this commit adds some useful helper functions to extract the current selected tab the continuation token. This is to mainly reduce code size and repetition. -- This cherry-picked commit also removes the code for parsing featured channels present on the original. (cherry picked from commit 8000d538dbbf1eb9c78e000b1449926ba3b24da9) --- src/invidious/helpers/extractors.cr | 117 +++++++++++++-- src/invidious/helpers/helpers.cr | 29 +++- src/invidious/helpers/invidiousitems.cr | 256 ++++++++++++++++++++++++++++++++ src/invidious/search.cr | 230 ---------------------------- src/invidious/views/components/item.ecr | 1 + 5 files changed, 389 insertions(+), 244 deletions(-) create mode 100644 src/invidious/helpers/invidiousitems.cr (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index e8daa913..1fa06c91 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -13,6 +13,7 @@ private ITEM_PARSERS = { ChannelParser.new, GridPlaylistParser.new, PlaylistParser.new, + CategoryParser.new, } private struct AuthorFallback @@ -95,7 +96,7 @@ end private class ChannelParser < ItemParser def process(item, author_fallback) - if item_contents = item["channelRenderer"]? + if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?) return self.parse(item_contents, author_fallback) end end @@ -194,6 +195,88 @@ private class PlaylistParser < ItemParser end end +private class CategoryParser < ItemParser + def process(item, author_fallback) + if item_contents = item["shelfRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + def parse(item_contents, author_fallback) + # Title extraction is a bit complicated. There are two possible routes for it + # as well as times when the title attribute just isn't sent by YT. + + title_container = item_contents["title"]? || "" + if !title_container.is_a? String + if title = title_container["simpleText"]? + title = title.as_s + else + title = title_container["runs"][0]["text"].as_s + end + else + title = "" + end + + browse_endpoint = item_contents["endpoint"]?.try &.["browseEndpoint"] || nil + browse_endpoint_data = "" + category_type = 0 # 0: Video, 1: Channels, 2: Playlist/feed, 3: trending + + # There's no endpoint data for video and trending category + if !item_contents["endpoint"]? + if !item_contents["videoId"]? + category_type = 3 + end + end + + if !browse_endpoint.nil? + # Playlist/feed categories doesn't need the params value (nor is it even included in yt response) + # instead it uses the browseId parameter. So if there isn't a params value we can assume the + # category is a playlist/feed + if browse_endpoint["params"]? + browse_endpoint_data = browse_endpoint["params"].as_s + category_type = 1 + else + browse_endpoint_data = browse_endpoint["browseId"].as_s + category_type = 2 + end + end + + # Sometimes a category can have badges. + badges = [] of Tuple(String, String) # (Badge style, label) + item_contents["badges"]?.try &.as_a.each do |badge| + badge = badge["metadataBadgeRenderer"] + badges << {badge["style"].as_s, badge["label"].as_s} + end + + # Content parsing + contents = [] of SearchItem + + # Content could be in three locations. + if content_container = item_contents["content"]["horizontalListRenderer"]? + elsif content_container = item_contents["content"]["expandedShelfContentsRenderer"] + elsif content_container = item_contents["content"]["verticalListRenderer"] + else + content_container = item_contents["contents"] + end + + raw_contents = content_container["items"].as_a + raw_contents.each do |item| + result = extract_item(item) + if !result.nil? + contents << result + end + end + + Category.new({ + title: title, + contents: contents, + browse_endpoint_data: browse_endpoint_data, + continuation_token: nil, + badges: badges, + }) + end +end + # The following are the extractors for extracting an array of items from # the internal Youtube API's JSON response. The result is then packaged into # a structure we can more easily use via the parsers above. Their internals are @@ -217,19 +300,16 @@ private class YoutubeTabsExtractor < ItemsContainerExtractor private def extract(target) raw_items = [] of JSON::Any selected_tab = extract_selected_tab(target["tabs"]) - content = selected_tab["tabRenderer"]["content"] + content = selected_tab["content"] content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| renderer_container = renderer_container["itemSectionRenderer"] renderer_container_contents = renderer_container["contents"].as_a[0] - # Shelf renderer usually refer to a category and would need special handling once - # An extractor for categories are added. But for now it is just used to - # extract items for the trending page + # Category extraction if items_container = renderer_container_contents["shelfRenderer"]? - if items_container["content"]["expandedShelfContentsRenderer"]? - items_container = items_container["content"]["expandedShelfContentsRenderer"] - end + raw_items << renderer_container_contents + next elsif items_container = renderer_container_contents["gridRenderer"]? else items_container = renderer_container_contents @@ -265,6 +345,8 @@ private class ContinuationExtractor < ItemsContainerExtractor def process(initial_data) if target = initial_data["continuationContents"]? self.extract(target) + elsif target = initial_data["appendContinuationItemsAction"]? + self.extract(target) end end @@ -272,13 +354,16 @@ private class ContinuationExtractor < ItemsContainerExtractor raw_items = [] of JSON::Any if content = target["gridContinuation"]? raw_items = content["items"].as_a + elsif content = target["continuationItems"]? + raw_items = content.as_a end return raw_items end end -def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fallback : String? = nil) +def extract_item(item : JSON::Any, author_fallback : String? = nil, + author_id_fallback : String? = nil) # Parses an item from Youtube's JSON response into a more usable structure. # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. author_fallback = AuthorFallback.new(author_fallback, author_id_fallback) @@ -295,13 +380,20 @@ def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fa # TODO radioRenderer, showRenderer, shelfRenderer, horizontalCardListRenderer, searchPyvRenderer end -def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) +def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, + author_id_fallback : String? = nil) items = [] of SearchItem - initial_data = initial_data["contents"]?.try &.as_h || initial_data["response"]?.try &.as_h || initial_data + + if unpackaged_data = initial_data["contents"]?.try &.as_h + elsif unpackaged_data = initial_data["response"]?.try &.as_h + elsif unpackaged_data = initial_data["onResponseReceivedActions"]?.try &.as_a.[0].as_h + else + unpackaged_data = initial_data + end # This is identicial to the parser cyling of extract_item(). ITEM_CONTAINER_EXTRACTOR.each do |extractor| - results = extractor.process(initial_data) + results = extractor.process(unpackaged_data) if !results.nil? results.each do |item| parsed_result = extract_item(item, author_fallback, author_id_fallback) @@ -310,6 +402,7 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri items << parsed_result end end + return items end end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 1a058195..a52c7bd4 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -248,12 +248,37 @@ def html_to_content(description_html : String) end def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) - extract_items(initial_data, author_fallback, author_id_fallback).select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) + extracted = extract_items(initial_data, author_fallback, author_id_fallback) + + if extracted.is_a?(Category) + target = extracted.contents + else + target = extracted + end + return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) end def extract_selected_tab(tabs) # Extract the selected tab from the array of tabs Youtube returns - return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0] + return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"] +end + +def fetch_continuation_token(items : Array(JSON::Any)) + # Fetches the continuation token from an array of items + return items.last["continuationItemRenderer"]? + .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s +end + +def fetch_continuation_token(initial_data : Hash(String, JSON::Any)) + # Fetches the continuation token from initial data + if initial_data["onResponseReceivedActions"]? + continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"] + else + tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]) + continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"] + end + + return fetch_continuation_token(continuation_items.as_a) end def check_enum(db, enum_name, struct_type = nil) diff --git a/src/invidious/helpers/invidiousitems.cr b/src/invidious/helpers/invidiousitems.cr new file mode 100644 index 00000000..50a47726 --- /dev/null +++ b/src/invidious/helpers/invidiousitems.cr @@ -0,0 +1,256 @@ +struct SearchVideo + include DB::Serializable + + property title : String + property id : String + property author : String + property ucid : String + property published : Time + property views : Int64 + property description_html : String + property length_seconds : Int32 + property live_now : Bool + property premium : Bool + property premiere_timestamp : Time? + + def to_xml(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?#{query_params}") + + 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?#{query_params}") do + xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") + end + + xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) } + 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") + xml.element("media:description") { xml.text html_to_content(self.description_html) } + end + + xml.element("media:community") do + xml.element("media:statistics", views: self.views) + end + end + end + + def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil) + if xml + to_xml(HOST_URL, auto_generated, query_params, xml) + else + XML.build do |json| + to_xml(HOST_URL, auto_generated, query_params, xml) + end + end + end + + def to_json(locale : Hash(String, JSON::Any), json : JSON::Builder) + json.object do + json.field "type", "video" + json.field "title", self.title + json.field "videoId", self.id + + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + + json.field "videoThumbnails" do + generate_thumbnails(json, self.id) + end + + json.field "description", html_to_content(self.description_html) + json.field "descriptionHtml", self.description_html + + json.field "viewCount", self.views + json.field "published", self.published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) + json.field "lengthSeconds", self.length_seconds + json.field "liveNow", self.live_now + json.field "premium", self.premium + json.field "isUpcoming", self.is_upcoming + + if self.premiere_timestamp + json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix + end + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end + + def is_upcoming + premiere_timestamp ? true : false + end +end + +struct SearchPlaylistVideo + include DB::Serializable + + property title : String + property id : String + property length_seconds : Int32 +end + +struct SearchPlaylist + include DB::Serializable + + property title : String + property id : String + property author : String + property ucid : String + property video_count : Int32 + property videos : Array(SearchPlaylistVideo) + property thumbnail : String? + + def to_json(locale, json : JSON::Builder) + 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 "videoCount", self.video_count + json.field "videos" do + json.array do + self.videos.each do |video| + json.object do + json.field "title", video.title + json.field "videoId", video.id + json.field "lengthSeconds", video.length_seconds + + json.field "videoThumbnails" do + generate_thumbnails(json, video.id) + end + end + end + end + end + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end +end + +struct SearchChannel + include DB::Serializable + + property author : String + property ucid : String + property author_thumbnail : String + property subscriber_count : Int32 + property video_count : Int32 + property description_html : String + property auto_generated : Bool + + def to_json(locale, json : JSON::Builder) + json.object do + json.field "type", "channel" + 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.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "autoGenerated", self.auto_generated + json.field "subCount", self.subscriber_count + json.field "videoCount", self.video_count + + json.field "description", html_to_content(self.description_html) + json.field "descriptionHtml", self.description_html + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end +end + +class Category + include DB::Serializable + + property title : String + property contents : Array(SearchItem) | SearchItem + property browse_endpoint_data : String? + property continuation_token : String? + property badges : Array(Tuple(String, String))? + + def to_json(locale, json : JSON::Builder) + json.object do + json.field "title", self.title + json.field "contents", self.contents + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end +end + +alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category diff --git a/src/invidious/search.cr b/src/invidious/search.cr index a3fcc7a3..eb9c37c5 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -1,233 +1,3 @@ -struct SearchVideo - include DB::Serializable - - property title : String - property id : String - property author : String - property ucid : String - property published : Time - property views : Int64 - property description_html : String - property length_seconds : Int32 - property live_now : Bool - property premium : Bool - property premiere_timestamp : Time? - - def to_xml(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?#{query_params}") - - 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?#{query_params}") do - xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") - end - - xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) } - 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") - xml.element("media:description") { xml.text html_to_content(self.description_html) } - end - - xml.element("media:community") do - xml.element("media:statistics", views: self.views) - end - end - end - - def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil) - if xml - to_xml(HOST_URL, auto_generated, query_params, xml) - else - XML.build do |json| - to_xml(HOST_URL, auto_generated, query_params, xml) - end - end - end - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "video" - json.field "title", self.title - json.field "videoId", self.id - - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - - json.field "videoThumbnails" do - generate_thumbnails(json, self.id) - end - - json.field "description", html_to_content(self.description_html) - json.field "descriptionHtml", self.description_html - - json.field "viewCount", self.views - json.field "published", self.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) - json.field "lengthSeconds", self.length_seconds - json.field "liveNow", self.live_now - json.field "premium", self.premium - json.field "isUpcoming", self.is_upcoming - - if self.premiere_timestamp - json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix - end - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end - - def is_upcoming - premiere_timestamp ? true : false - end -end - -struct SearchPlaylistVideo - include DB::Serializable - - property title : String - property id : String - property length_seconds : Int32 -end - -struct SearchPlaylist - include DB::Serializable - - property title : String - property id : String - property author : String - property ucid : String - property video_count : Int32 - property videos : Array(SearchPlaylistVideo) - property thumbnail : String? - - def to_json(locale, json : JSON::Builder) - 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 "videoCount", self.video_count - json.field "videos" do - json.array do - self.videos.each do |video| - json.object do - json.field "title", video.title - json.field "videoId", video.id - json.field "lengthSeconds", video.length_seconds - - json.field "videoThumbnails" do - generate_thumbnails(json, video.id) - end - end - end - end - end - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -struct SearchChannel - include DB::Serializable - - property author : String - property ucid : String - property author_thumbnail : String - property subscriber_count : Int32 - property video_count : Int32 - property description_html : String - property auto_generated : Bool - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "channel" - 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.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "autoGenerated", self.auto_generated - json.field "subCount", self.subscriber_count - json.field "videoCount", self.video_count - - json.field "description", html_to_content(self.description_html) - json.field "descriptionHtml", self.description_html - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist - def channel_search(query, page, channel) response = YT_POOL.client &.get("/channel/#{channel}") diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 68aa1812..ec282216 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -96,6 +96,7 @@
<% end %>
+ <% when Category %> <% else %> <% if !env.get("preferences").as(Preferences).thin_mode %> -- cgit v1.2.3 From ae30f32c36c738b85dc114a4bb4edaa95257a3c2 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 8 May 2021 03:43:26 -0700 Subject: Unpack search items that are embedded in categories This is a squash of a bunch of commits cherry-picked commits Fix category parse error on search (cherry picked from commit cc02fed4e69f0eb5f19e017173632b3a3f20519f) Fix category items not being extracted in search (cherry picked from commit 2605b9c609ff217b5a6ae09d22450596dcad90fc) Make search not include category items for now (cherry picked from commit ca4afd59f46b595e3c339f31432cad98a5771ee1) Change behavior of categories in search results (cherry picked from commit cc1067561051b1c113b490e79c4a71cd346f7b3f) Fix missing search results in extraction (cherry picked from commit abda6840d5bfe58f845128bdd1a3f4916dd3bb84) Fix miscount of search results (cherry picked from commit 491e33450eb1300d0234bb33df0d0e78a027114f) --- src/invidious/helpers/extractors.cr | 15 ++++++++++----- src/invidious/search.cr | 17 ++++++++++++++++- 2 files changed, 26 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 1fa06c91..ea9411d7 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -253,8 +253,8 @@ private class CategoryParser < ItemParser # Content could be in three locations. if content_container = item_contents["content"]["horizontalListRenderer"]? - elsif content_container = item_contents["content"]["expandedShelfContentsRenderer"] - elsif content_container = item_contents["content"]["verticalListRenderer"] + elsif content_container = item_contents["content"]["expandedShelfContentsRenderer"]? + elsif content_container = item_contents["content"]["verticalListRenderer"]? else content_container = item_contents["contents"] end @@ -332,10 +332,15 @@ private class SearchResultsExtractor < ItemsContainerExtractor end private def extract(target) - raw_items = [] of JSON::Any + raw_items = [] of Array(JSON::Any) content = target["primaryContents"] - renderer = content["sectionListRenderer"]["contents"].as_a[0]["itemSectionRenderer"] - raw_items = renderer["contents"].as_a + renderer = content["sectionListRenderer"]["contents"].as_a.each do |node| + if node = node["itemSectionRenderer"]? + raw_items << node["contents"].as_a + end + end + + raw_items = raw_items.flatten return raw_items end diff --git a/src/invidious/search.cr b/src/invidious/search.cr index eb9c37c5..3873b2dd 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -232,5 +232,20 @@ def process_search_query(query, page, user, region) count, items = search(search_query, search_params, region).as(Tuple) end - {search_query, count, items, operators} + # Light processing to flatten search results out of Categories. + # They should ideally be supported in the future. + items_without_cate_items = [] of SearchItem | ChannelVideo + items.each do |i| + if i.is_a? Category + i.contents.each do |nest_i| + if !nest_i.is_a? Video + items_without_cate_items << nest_i + end + end + else + items_without_cate_items << i + end + end + + {search_query, items_without_cate_items.size, items_without_cate_items, url_params} end -- cgit v1.2.3 From 57c63f3598867ce406b807923ea81352f9b1b384 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 28 Jun 2021 22:51:28 -0700 Subject: Rename "items_without_cate_items" to reflect usage --- src/invidious/search.cr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 3873b2dd..adf079f3 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -234,18 +234,18 @@ def process_search_query(query, page, user, region) # Light processing to flatten search results out of Categories. # They should ideally be supported in the future. - items_without_cate_items = [] of SearchItem | ChannelVideo + items_without_category = [] of SearchItem | ChannelVideo items.each do |i| if i.is_a? Category i.contents.each do |nest_i| if !nest_i.is_a? Video - items_without_cate_items << nest_i + items_without_category << nest_i end end else - items_without_cate_items << i + items_without_category << i end end - {search_query, items_without_cate_items.size, items_without_cate_items, url_params} + {search_query, items_without_category.size, items_without_category, url_params} end -- cgit v1.2.3 From 0b7a108a59b2f1def6aea5b611f68b29abf59064 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 8 May 2021 04:54:12 -0700 Subject: Move continuation_token out of Category struct (cherry picked from commit 0e96eda28f25171a0344b972af1852a4d6fc3007) --- src/invidious/helpers/extractors.cr | 11 +++++++++-- src/invidious/helpers/invidiousitems.cr | 1 - 2 files changed, 9 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index ea9411d7..cd3b1f93 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -217,6 +217,7 @@ private class CategoryParser < ItemParser title = "" end + auxiliary_data = {} of String => String browse_endpoint = item_contents["endpoint"]?.try &.["browseEndpoint"] || nil browse_endpoint_data = "" category_type = 0 # 0: Video, 1: Channels, 2: Playlist/feed, 3: trending @@ -233,7 +234,14 @@ private class CategoryParser < ItemParser # instead it uses the browseId parameter. So if there isn't a params value we can assume the # category is a playlist/feed if browse_endpoint["params"]? - browse_endpoint_data = browse_endpoint["params"].as_s + # However, even though the channel category type returns the browse endpoint param + # we're not going to be using it in order to preserve compatablity with Youtube. + # and for an URL that looks cleaner + url = item_contents["endpoint"]["commandMetadata"]["webCommandMetadata"]["url"] + url = URI.parse(url.as_s) + auxiliary_data["view"] = url.query_params["view"] + auxiliary_data["shelf_id"] = url.query_params["shelf_id"] + category_type = 1 else browse_endpoint_data = browse_endpoint["browseId"].as_s @@ -271,7 +279,6 @@ private class CategoryParser < ItemParser title: title, contents: contents, browse_endpoint_data: browse_endpoint_data, - continuation_token: nil, badges: badges, }) end diff --git a/src/invidious/helpers/invidiousitems.cr b/src/invidious/helpers/invidiousitems.cr index 50a47726..edcb2054 100644 --- a/src/invidious/helpers/invidiousitems.cr +++ b/src/invidious/helpers/invidiousitems.cr @@ -232,7 +232,6 @@ class Category property title : String property contents : Array(SearchItem) | SearchItem property browse_endpoint_data : String? - property continuation_token : String? property badges : Array(Tuple(String, String))? def to_json(locale, json : JSON::Builder) -- cgit v1.2.3 From ea6434662daf97e8710fe4d2a4943112994ce760 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 8 May 2021 06:01:17 -0700 Subject: Change typing of Category contents to only Array (cherry picked from commit d3384e17f10d0baca70db7993df14100485be9da) --- src/invidious/helpers/invidiousitems.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/invidiousitems.cr b/src/invidious/helpers/invidiousitems.cr index edcb2054..65f755e6 100644 --- a/src/invidious/helpers/invidiousitems.cr +++ b/src/invidious/helpers/invidiousitems.cr @@ -230,7 +230,7 @@ class Category include DB::Serializable property title : String - property contents : Array(SearchItem) | SearchItem + property contents : Array(SearchItem) property browse_endpoint_data : String? property badges : Array(Tuple(String, String))? -- cgit v1.2.3 From 7b60dac526c5df118c39bf428c0778a7a7982c98 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 8 May 2021 20:07:07 -0700 Subject: Add description_html field to Category (cherry picked from commit aa8f15f795787113e56473f8e8fd606749a14bdd) --- src/invidious/helpers/extractors.cr | 4 ++++ src/invidious/helpers/invidiousitems.cr | 1 + 2 files changed, 5 insertions(+) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index cd3b1f93..48885d48 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -256,6 +256,9 @@ private class CategoryParser < ItemParser badges << {badge["style"].as_s, badge["label"].as_s} end + # Category description + description_html = item_contents["subtitle"]?.try { |desc| parse_content(desc) } || "" + # Content parsing contents = [] of SearchItem @@ -278,6 +281,7 @@ private class CategoryParser < ItemParser Category.new({ title: title, contents: contents, + description_html: description_html, browse_endpoint_data: browse_endpoint_data, badges: badges, }) diff --git a/src/invidious/helpers/invidiousitems.cr b/src/invidious/helpers/invidiousitems.cr index 65f755e6..2db838ea 100644 --- a/src/invidious/helpers/invidiousitems.cr +++ b/src/invidious/helpers/invidiousitems.cr @@ -232,6 +232,7 @@ class Category property title : String property contents : Array(SearchItem) property browse_endpoint_data : String? + property description_html : String property badges : Array(Tuple(String, String))? def to_json(locale, json : JSON::Builder) -- cgit v1.2.3 From abca8f7a7ca043035459abce35d334013a71e957 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 24 May 2021 11:18:22 -0700 Subject: Rename invidiousitems.cr --- src/invidious/helpers/invidiousitems.cr | 256 ---------------------------- src/invidious/helpers/serialized_yt_data.cr | 256 ++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 256 deletions(-) delete mode 100644 src/invidious/helpers/invidiousitems.cr create mode 100644 src/invidious/helpers/serialized_yt_data.cr (limited to 'src') diff --git a/src/invidious/helpers/invidiousitems.cr b/src/invidious/helpers/invidiousitems.cr deleted file mode 100644 index 2db838ea..00000000 --- a/src/invidious/helpers/invidiousitems.cr +++ /dev/null @@ -1,256 +0,0 @@ -struct SearchVideo - include DB::Serializable - - property title : String - property id : String - property author : String - property ucid : String - property published : Time - property views : Int64 - property description_html : String - property length_seconds : Int32 - property live_now : Bool - property premium : Bool - property premiere_timestamp : Time? - - def to_xml(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?#{query_params}") - - 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?#{query_params}") do - xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") - end - - xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) } - 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") - xml.element("media:description") { xml.text html_to_content(self.description_html) } - end - - xml.element("media:community") do - xml.element("media:statistics", views: self.views) - end - end - end - - def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil) - if xml - to_xml(HOST_URL, auto_generated, query_params, xml) - else - XML.build do |json| - to_xml(HOST_URL, auto_generated, query_params, xml) - end - end - end - - def to_json(locale : Hash(String, JSON::Any), json : JSON::Builder) - json.object do - json.field "type", "video" - json.field "title", self.title - json.field "videoId", self.id - - json.field "author", self.author - json.field "authorId", self.ucid - json.field "authorUrl", "/channel/#{self.ucid}" - - json.field "videoThumbnails" do - generate_thumbnails(json, self.id) - end - - json.field "description", html_to_content(self.description_html) - json.field "descriptionHtml", self.description_html - - json.field "viewCount", self.views - json.field "published", self.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) - json.field "lengthSeconds", self.length_seconds - json.field "liveNow", self.live_now - json.field "premium", self.premium - json.field "isUpcoming", self.is_upcoming - - if self.premiere_timestamp - json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix - end - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end - - def is_upcoming - premiere_timestamp ? true : false - end -end - -struct SearchPlaylistVideo - include DB::Serializable - - property title : String - property id : String - property length_seconds : Int32 -end - -struct SearchPlaylist - include DB::Serializable - - property title : String - property id : String - property author : String - property ucid : String - property video_count : Int32 - property videos : Array(SearchPlaylistVideo) - property thumbnail : String? - - def to_json(locale, json : JSON::Builder) - 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 "videoCount", self.video_count - json.field "videos" do - json.array do - self.videos.each do |video| - json.object do - json.field "title", video.title - json.field "videoId", video.id - json.field "lengthSeconds", video.length_seconds - - json.field "videoThumbnails" do - generate_thumbnails(json, video.id) - end - end - end - end - end - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -struct SearchChannel - include DB::Serializable - - property author : String - property ucid : String - property author_thumbnail : String - property subscriber_count : Int32 - property video_count : Int32 - property description_html : String - property auto_generated : Bool - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "type", "channel" - 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.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "autoGenerated", self.auto_generated - json.field "subCount", self.subscriber_count - json.field "videoCount", self.video_count - - json.field "description", html_to_content(self.description_html) - json.field "descriptionHtml", self.description_html - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -class Category - include DB::Serializable - - property title : String - property contents : Array(SearchItem) - property browse_endpoint_data : String? - property description_html : String - property badges : Array(Tuple(String, String))? - - def to_json(locale, json : JSON::Builder) - json.object do - json.field "title", self.title - json.field "contents", self.contents - end - end - - def to_json(locale, json : JSON::Builder | Nil = nil) - if json - to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end - end - end -end - -alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr new file mode 100644 index 00000000..2db838ea --- /dev/null +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -0,0 +1,256 @@ +struct SearchVideo + include DB::Serializable + + property title : String + property id : String + property author : String + property ucid : String + property published : Time + property views : Int64 + property description_html : String + property length_seconds : Int32 + property live_now : Bool + property premium : Bool + property premiere_timestamp : Time? + + def to_xml(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?#{query_params}") + + 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?#{query_params}") do + xml.element("img", src: "#{HOST_URL}/vi/#{self.id}/mqdefault.jpg") + end + + xml.element("p", style: "word-break:break-word;white-space:pre-wrap") { xml.text html_to_content(self.description_html) } + 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") + xml.element("media:description") { xml.text html_to_content(self.description_html) } + end + + xml.element("media:community") do + xml.element("media:statistics", views: self.views) + end + end + end + + def to_xml(auto_generated, query_params, xml : XML::Builder | Nil = nil) + if xml + to_xml(HOST_URL, auto_generated, query_params, xml) + else + XML.build do |json| + to_xml(HOST_URL, auto_generated, query_params, xml) + end + end + end + + def to_json(locale : Hash(String, JSON::Any), json : JSON::Builder) + json.object do + json.field "type", "video" + json.field "title", self.title + json.field "videoId", self.id + + json.field "author", self.author + json.field "authorId", self.ucid + json.field "authorUrl", "/channel/#{self.ucid}" + + json.field "videoThumbnails" do + generate_thumbnails(json, self.id) + end + + json.field "description", html_to_content(self.description_html) + json.field "descriptionHtml", self.description_html + + json.field "viewCount", self.views + json.field "published", self.published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) + json.field "lengthSeconds", self.length_seconds + json.field "liveNow", self.live_now + json.field "premium", self.premium + json.field "isUpcoming", self.is_upcoming + + if self.premiere_timestamp + json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix + end + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end + + def is_upcoming + premiere_timestamp ? true : false + end +end + +struct SearchPlaylistVideo + include DB::Serializable + + property title : String + property id : String + property length_seconds : Int32 +end + +struct SearchPlaylist + include DB::Serializable + + property title : String + property id : String + property author : String + property ucid : String + property video_count : Int32 + property videos : Array(SearchPlaylistVideo) + property thumbnail : String? + + def to_json(locale, json : JSON::Builder) + 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 "videoCount", self.video_count + json.field "videos" do + json.array do + self.videos.each do |video| + json.object do + json.field "title", video.title + json.field "videoId", video.id + json.field "lengthSeconds", video.length_seconds + + json.field "videoThumbnails" do + generate_thumbnails(json, video.id) + end + end + end + end + end + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end +end + +struct SearchChannel + include DB::Serializable + + property author : String + property ucid : String + property author_thumbnail : String + property subscriber_count : Int32 + property video_count : Int32 + property description_html : String + property auto_generated : Bool + + def to_json(locale, json : JSON::Builder) + json.object do + json.field "type", "channel" + 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.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "autoGenerated", self.auto_generated + json.field "subCount", self.subscriber_count + json.field "videoCount", self.video_count + + json.field "description", html_to_content(self.description_html) + json.field "descriptionHtml", self.description_html + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end +end + +class Category + include DB::Serializable + + property title : String + property contents : Array(SearchItem) + property browse_endpoint_data : String? + property description_html : String + property badges : Array(Tuple(String, String))? + + def to_json(locale, json : JSON::Builder) + json.object do + json.field "title", self.title + json.field "contents", self.contents + end + end + + def to_json(locale, json : JSON::Builder | Nil = nil) + if json + to_json(locale, json) + else + JSON.build do |json| + to_json(locale, json) + end + end + end +end + +alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category -- cgit v1.2.3 From be1a43a3377c543b84fd9bd534fd2033b7223e62 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 28 Jun 2021 23:11:04 -0700 Subject: Manually extract category refactor from 1b569bbc99207cae7c20aa285f42477ae361dd30 Also fixes some errors caused by cherry-picking --- spec/helpers_spec.cr | 1 + src/invidious/helpers/extractors.cr | 43 ++++------------------------- src/invidious/helpers/serialized_yt_data.cr | 4 +-- src/invidious/search.cr | 2 +- src/invidious/videos.cr | 2 +- 5 files changed, 11 insertions(+), 41 deletions(-) (limited to 'src') diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index ada5b28f..b17c8d73 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -6,6 +6,7 @@ require "spec" require "yaml" require "../src/invidious/helpers/*" require "../src/invidious/channels/*" +require "../src/invidious/videos" require "../src/invidious/comments" require "../src/invidious/playlists" require "../src/invidious/search" diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 48885d48..c1f7205c 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -205,7 +205,6 @@ private class CategoryParser < ItemParser def parse(item_contents, author_fallback) # Title extraction is a bit complicated. There are two possible routes for it # as well as times when the title attribute just isn't sent by YT. - title_container = item_contents["title"]? || "" if !title_container.is_a? String if title = title_container["simpleText"]? @@ -217,37 +216,7 @@ private class CategoryParser < ItemParser title = "" end - auxiliary_data = {} of String => String - browse_endpoint = item_contents["endpoint"]?.try &.["browseEndpoint"] || nil - browse_endpoint_data = "" - category_type = 0 # 0: Video, 1: Channels, 2: Playlist/feed, 3: trending - - # There's no endpoint data for video and trending category - if !item_contents["endpoint"]? - if !item_contents["videoId"]? - category_type = 3 - end - end - - if !browse_endpoint.nil? - # Playlist/feed categories doesn't need the params value (nor is it even included in yt response) - # instead it uses the browseId parameter. So if there isn't a params value we can assume the - # category is a playlist/feed - if browse_endpoint["params"]? - # However, even though the channel category type returns the browse endpoint param - # we're not going to be using it in order to preserve compatablity with Youtube. - # and for an URL that looks cleaner - url = item_contents["endpoint"]["commandMetadata"]["webCommandMetadata"]["url"] - url = URI.parse(url.as_s) - auxiliary_data["view"] = url.query_params["view"] - auxiliary_data["shelf_id"] = url.query_params["shelf_id"] - - category_type = 1 - else - browse_endpoint_data = browse_endpoint["browseId"].as_s - category_type = 2 - end - end + url = item_contents["endpoint"]?.try &.["commandMetadata"]["webCommandMetadata"]["url"].as_s # Sometimes a category can have badges. badges = [] of Tuple(String, String) # (Badge style, label) @@ -279,11 +248,11 @@ private class CategoryParser < ItemParser end Category.new({ - title: title, - contents: contents, - description_html: description_html, - browse_endpoint_data: browse_endpoint_data, - badges: badges, + title: title, + contents: contents, + description_html: description_html, + url: url, + badges: badges, }) end end diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 2db838ea..61356555 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -230,8 +230,8 @@ class Category include DB::Serializable property title : String - property contents : Array(SearchItem) - property browse_endpoint_data : String? + property contents : Array(SearchItem) | Array(Video) + property url : String? property description_html : String property badges : Array(Tuple(String, String))? diff --git a/src/invidious/search.cr b/src/invidious/search.cr index adf079f3..d95d802e 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -247,5 +247,5 @@ def process_search_query(query, page, user, region) end end - {search_query, items_without_category.size, items_without_category, url_params} + {search_query, items_without_category.size, items_without_category, operators} end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d9c07142..0e6bd77c 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -275,7 +275,7 @@ struct Video end end - def to_json(locale, json : JSON::Builder) + def to_json(locale : Hash(String, JSON::Any), json : JSON::Builder) json.object do json.field "type", "video" -- cgit v1.2.3 From 30e85b40f9b817c8620ef9536ad2d327da9ba83b Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 28 Jun 2021 23:51:04 -0700 Subject: Fix extract_videos --- src/invidious/helpers/helpers.cr | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index a52c7bd4..99adcd30 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -250,10 +250,13 @@ end def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) extracted = extract_items(initial_data, author_fallback, author_id_fallback) - if extracted.is_a?(Category) - target = extracted.contents - else - target = extracted + target = [] of SearchItem + extracted.each do |i| + if i.is_a?(Category) + i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video } + else + target << i + end end return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) end -- cgit v1.2.3 From 8435e7991337edcb007b82c148a372a0a678b5c1 Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 29 Jun 2021 09:23:48 -0700 Subject: Improve documentation for extract_item(s) funcs --- src/invidious/helpers/extractors.cr | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index c1f7205c..e8226888 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -347,10 +347,10 @@ private class ContinuationExtractor < ItemsContainerExtractor end end +# Parses an item from Youtube's JSON response into a more usable structure. +# The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. def extract_item(item : JSON::Any, author_fallback : String? = nil, author_id_fallback : String? = nil) - # Parses an item from Youtube's JSON response into a more usable structure. - # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. author_fallback = AuthorFallback.new(author_fallback, author_id_fallback) # Cycles through all of the item parsers and attempt to parse the raw YT JSON data. @@ -365,8 +365,10 @@ def extract_item(item : JSON::Any, author_fallback : String? = nil, # TODO radioRenderer, showRenderer, shelfRenderer, horizontalCardListRenderer, searchPyvRenderer end +# Parses multiple items from Youtube's initial JSON response into a more usable structure. +# The end result is an array of SearchItem. def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, - author_id_fallback : String? = nil) + author_id_fallback : String? = nil) : Array(SearchItem) items = [] of SearchItem if unpackaged_data = initial_data["contents"]?.try &.as_h -- cgit v1.2.3 From 3dea670091b0fc4a20d623c928292f7bd94892d8 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 19 Jul 2021 21:30:41 -0700 Subject: Switch to structs in extractors.cr for performance --- src/invidious/helpers/extractors.cr | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index e8226888..68e84850 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -27,7 +27,7 @@ end # They're accessed through the process() method which validates the given data as applicable # to their specific struct and then use the internal parse() method to assemble the struct # specific to their category. -private class ItemParser +private abstract struct ItemParser # Base type for all item parsers. def process(item : JSON::Any, author_fallback : AuthorFallback) end @@ -36,7 +36,7 @@ private class ItemParser end end -private class VideoParser < ItemParser +private struct VideoParser < ItemParser def process(item, author_fallback) if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?) return self.parse(item_contents, author_fallback) @@ -94,7 +94,7 @@ private class VideoParser < ItemParser end end -private class ChannelParser < ItemParser +private struct ChannelParser < ItemParser def process(item, author_fallback) if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?) return self.parse(item_contents, author_fallback) @@ -125,7 +125,7 @@ private class ChannelParser < ItemParser end end -private class GridPlaylistParser < ItemParser +private struct GridPlaylistParser < ItemParser def process(item, author_fallback) if item_contents = item["gridPlaylistRenderer"]? return self.parse(item_contents, author_fallback) @@ -151,7 +151,7 @@ private class GridPlaylistParser < ItemParser end end -private class PlaylistParser < ItemParser +private struct PlaylistParser < ItemParser def process(item, author_fallback) if item_contents = item["playlistRenderer"]? return self.parse(item_contents, author_fallback) @@ -195,7 +195,7 @@ private class PlaylistParser < ItemParser end end -private class CategoryParser < ItemParser +private struct CategoryParser < ItemParser def process(item, author_fallback) if item_contents = item["shelfRenderer"]? return self.parse(item_contents, author_fallback) @@ -262,7 +262,7 @@ end # a structure we can more easily use via the parsers above. Their internals are # identical to the item parsers. -private class ItemsContainerExtractor +private abstract struct ItemsContainerExtractor def process(item : Hash(String, JSON::Any)) end @@ -270,7 +270,7 @@ private class ItemsContainerExtractor end end -private class YoutubeTabsExtractor < ItemsContainerExtractor +private struct YoutubeTabsExtractor < ItemsContainerExtractor def process(initial_data) if target = initial_data["twoColumnBrowseResultsRenderer"]? self.extract(target) @@ -304,7 +304,7 @@ private class YoutubeTabsExtractor < ItemsContainerExtractor end end -private class SearchResultsExtractor < ItemsContainerExtractor +private struct SearchResultsExtractor < ItemsContainerExtractor def process(initial_data) if target = initial_data["twoColumnSearchResultsRenderer"]? self.extract(target) @@ -326,7 +326,7 @@ private class SearchResultsExtractor < ItemsContainerExtractor end end -private class ContinuationExtractor < ItemsContainerExtractor +private struct ContinuationExtractor < ItemsContainerExtractor def process(initial_data) if target = initial_data["continuationContents"]? self.extract(target) -- cgit v1.2.3 From 142317c2be064f8114c7d75f9ae336eb6a6e96a3 Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 3 Aug 2021 00:22:31 -0700 Subject: Overhaul extractors.cr to use modules --- src/invidious/helpers/extractors.cr | 528 +++++++++++++++++------------------- 1 file changed, 255 insertions(+), 273 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 68e84850..cec0e728 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -3,257 +3,245 @@ # Tuple of Parsers/Extractors so we can easily cycle through them. private ITEM_CONTAINER_EXTRACTOR = { - YoutubeTabsExtractor.new, - SearchResultsExtractor.new, - ContinuationExtractor.new, + Extractors::YouTubeTabs, + Extractors::SearchResults, + Extractors::Continuation, } private ITEM_PARSERS = { - VideoParser.new, - ChannelParser.new, - GridPlaylistParser.new, - PlaylistParser.new, - CategoryParser.new, + Parsers::VideoRendererParser, + Parsers::ChannelRendererParser, + Parsers::GridPlaylistRendererParser, + Parsers::PlaylistRendererParser, + Parsers::CategoryRendererParser, } -private struct AuthorFallback - property name, id - - def initialize(@name : String? = nil, @id : String? = nil) - end -end +record AuthorFallback, name : String? = nil, id : String? = nil # The following are the parsers for parsing raw item data into neatly packaged structs. # They're accessed through the process() method which validates the given data as applicable # to their specific struct and then use the internal parse() method to assemble the struct # specific to their category. -private abstract struct ItemParser - # Base type for all item parsers. - def process(item : JSON::Any, author_fallback : AuthorFallback) - end +private module Parsers + module VideoRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?) + return self.parse(item_contents, author_fallback) + end + end - private def parse(item_contents : JSON::Any, author_fallback : AuthorFallback) - end -end + private def self.parse(item_contents, author_fallback) + video_id = item_contents["videoId"].as_s + title = item_contents["title"].try { |t| t["simpleText"]?.try &.as_s || t["runs"]?.try &.as_a.map(&.["text"].as_s).join("") } || "" + + author_info = item_contents["ownerText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? + author = author_info.try &.["text"].as_s || author_fallback.name || "" + author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_fallback.id || "" + + published = item_contents["publishedTimeText"]?.try &.["simpleText"]?.try { |t| decode_date(t.as_s) } || Time.local + view_count = item_contents["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 + description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" + length_seconds = item_contents["lengthText"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || + item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?).try &.["thumbnailOverlayTimeStatusRenderer"]? + .try &.["text"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || 0 + + live_now = false + paid = false + premium = false + + premiere_timestamp = item_contents["upcomingEventData"]?.try &.["startTime"]?.try { |t| Time.unix(t.as_s.to_i64) } + + item_contents["badges"]?.try &.as_a.each do |badge| + b = badge["metadataBadgeRenderer"] + case b["label"].as_s + when "LIVE NOW" + live_now = true + when "New", "4K", "CC" + # TODO + when "Premium" + # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] + premium = true + else nil # Ignore + end + end -private struct VideoParser < ItemParser - def process(item, author_fallback) - if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?) - return self.parse(item_contents, author_fallback) + SearchVideo.new({ + title: title, + id: video_id, + author: author, + ucid: author_id, + published: published, + views: view_count, + description_html: description_html, + length_seconds: length_seconds, + live_now: live_now, + premium: premium, + premiere_timestamp: premiere_timestamp, + }) end end - private def parse(item_contents, author_fallback) - video_id = item_contents["videoId"].as_s - title = item_contents["title"].try { |t| t["simpleText"]?.try &.as_s || t["runs"]?.try &.as_a.map(&.["text"].as_s).join("") } || "" - - author_info = item_contents["ownerText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? - author = author_info.try &.["text"].as_s || author_fallback.name || "" - author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_fallback.id || "" - - published = item_contents["publishedTimeText"]?.try &.["simpleText"]?.try { |t| decode_date(t.as_s) } || Time.local - view_count = item_contents["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 - description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" - length_seconds = item_contents["lengthText"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || - item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?).try &.["thumbnailOverlayTimeStatusRenderer"]? - .try &.["text"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || 0 - - live_now = false - paid = false - premium = false - - premiere_timestamp = item_contents["upcomingEventData"]?.try &.["startTime"]?.try { |t| Time.unix(t.as_s.to_i64) } - - item_contents["badges"]?.try &.as_a.each do |badge| - b = badge["metadataBadgeRenderer"] - case b["label"].as_s - when "LIVE NOW" - live_now = true - when "New", "4K", "CC" - # TODO - when "Premium" - # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] - premium = true - else nil # Ignore + module ChannelRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?) + return self.parse(item_contents, author_fallback) end end - SearchVideo.new({ - title: title, - id: video_id, - author: author, - ucid: author_id, - published: published, - views: view_count, - description_html: description_html, - length_seconds: length_seconds, - live_now: live_now, - premium: premium, - premiere_timestamp: premiere_timestamp, - }) - end -end - -private struct ChannelParser < ItemParser - def process(item, author_fallback) - if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?) - return self.parse(item_contents, author_fallback) + private def self.parse(item_contents, author_fallback) + author = item_contents["title"]["simpleText"]?.try &.as_s || author_fallback.name || "" + author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id || "" + + author_thumbnail = item_contents["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try &.["url"]?.try &.as_s || "" + subscriber_count = item_contents["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0 + + auto_generated = false + auto_generated = true if !item_contents["videoCountText"]? + video_count = item_contents["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 + description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" + + SearchChannel.new({ + author: author, + ucid: author_id, + author_thumbnail: author_thumbnail, + subscriber_count: subscriber_count, + video_count: video_count, + description_html: description_html, + auto_generated: auto_generated, + }) end end - private def parse(item_contents, author_fallback) - author = item_contents["title"]["simpleText"]?.try &.as_s || author_fallback.name || "" - author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id || "" - - author_thumbnail = item_contents["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try &.["url"]?.try &.as_s || "" - subscriber_count = item_contents["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0 - - auto_generated = false - auto_generated = true if !item_contents["videoCountText"]? - video_count = item_contents["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 - description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" - - SearchChannel.new({ - author: author, - ucid: author_id, - author_thumbnail: author_thumbnail, - subscriber_count: subscriber_count, - video_count: video_count, - description_html: description_html, - auto_generated: auto_generated, - }) - end -end - -private struct GridPlaylistParser < ItemParser - def process(item, author_fallback) - if item_contents = item["gridPlaylistRenderer"]? - return self.parse(item_contents, author_fallback) + module GridPlaylistRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["gridPlaylistRenderer"]? + return self.parse(item_contents, author_fallback) + end end - end - private def parse(item_contents, author_fallback) - title = item_contents["title"]["runs"].as_a[0]?.try &.["text"].as_s || "" - plid = item_contents["playlistId"]?.try &.as_s || "" - - video_count = item_contents["videoCountText"]["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 - playlist_thumbnail = item_contents["thumbnail"]["thumbnails"][0]?.try &.["url"]?.try &.as_s || "" - - SearchPlaylist.new({ - title: title, - id: plid, - author: author_fallback.name || "", - ucid: author_fallback.id || "", - video_count: video_count, - videos: [] of SearchPlaylistVideo, - thumbnail: playlist_thumbnail, - }) + private def self.parse(item_contents, author_fallback) + title = item_contents["title"]["runs"].as_a[0]?.try &.["text"].as_s || "" + plid = item_contents["playlistId"]?.try &.as_s || "" + + video_count = item_contents["videoCountText"]["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 + playlist_thumbnail = item_contents["thumbnail"]["thumbnails"][0]?.try &.["url"]?.try &.as_s || "" + + SearchPlaylist.new({ + title: title, + id: plid, + author: author_fallback.name || "", + ucid: author_fallback.id || "", + video_count: video_count, + videos: [] of SearchPlaylistVideo, + thumbnail: playlist_thumbnail, + }) + end end -end -private struct PlaylistParser < ItemParser - def process(item, author_fallback) - if item_contents = item["playlistRenderer"]? - return self.parse(item_contents, author_fallback) + module PlaylistRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["playlistRenderer"]? + return self.parse(item_contents, author_fallback) + end end - end - def parse(item_contents, author_fallback) - title = item_contents["title"]["simpleText"]?.try &.as_s || "" - plid = item_contents["playlistId"]?.try &.as_s || "" - - video_count = item_contents["videoCount"]?.try &.as_s.to_i || 0 - playlist_thumbnail = item_contents["thumbnails"].as_a[0]?.try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"].as_s || "" - - author_info = item_contents["shortBylineText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? - author = author_info.try &.["text"].as_s || author_fallback.name || "" - author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_fallback.id || "" - - videos = item_contents["videos"]?.try &.as_a.map do |v| - v = v["childVideoRenderer"] - v_title = v["title"]["simpleText"]?.try &.as_s || "" - v_id = v["videoId"]?.try &.as_s || "" - v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0 - SearchPlaylistVideo.new({ - title: v_title, - id: v_id, - length_seconds: v_length_seconds, + private def self.parse(item_contents, author_fallback) + title = item_contents["title"]["simpleText"]?.try &.as_s || "" + plid = item_contents["playlistId"]?.try &.as_s || "" + + video_count = item_contents["videoCount"]?.try &.as_s.to_i || 0 + playlist_thumbnail = item_contents["thumbnails"].as_a[0]?.try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"].as_s || "" + + author_info = item_contents["shortBylineText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? + author = author_info.try &.["text"].as_s || author_fallback.name || "" + author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_fallback.id || "" + + videos = item_contents["videos"]?.try &.as_a.map do |v| + v = v["childVideoRenderer"] + v_title = v["title"]["simpleText"]?.try &.as_s || "" + v_id = v["videoId"]?.try &.as_s || "" + v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0 + SearchPlaylistVideo.new({ + title: v_title, + id: v_id, + length_seconds: v_length_seconds, + }) + end || [] of SearchPlaylistVideo + + # TODO: item_contents["publishedTimeText"]? + + SearchPlaylist.new({ + title: title, + id: plid, + author: author, + ucid: author_id, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail, }) - end || [] of SearchPlaylistVideo - - # TODO: item_contents["publishedTimeText"]? - - SearchPlaylist.new({ - title: title, - id: plid, - author: author, - ucid: author_id, - video_count: video_count, - videos: videos, - thumbnail: playlist_thumbnail, - }) + end end -end -private struct CategoryParser < ItemParser - def process(item, author_fallback) - if item_contents = item["shelfRenderer"]? - return self.parse(item_contents, author_fallback) + module CategoryRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["shelfRenderer"]? + return self.parse(item_contents, author_fallback) + end end - end - def parse(item_contents, author_fallback) - # Title extraction is a bit complicated. There are two possible routes for it - # as well as times when the title attribute just isn't sent by YT. - title_container = item_contents["title"]? || "" - if !title_container.is_a? String - if title = title_container["simpleText"]? - title = title.as_s + private def self.parse(item_contents, author_fallback) + # Title extraction is a bit complicated. There are two possible routes for it + # as well as times when the title attribute just isn't sent by YT. + title_container = item_contents["title"]? || "" + if !title_container.is_a? String + if title = title_container["simpleText"]? + title = title.as_s + else + title = title_container["runs"][0]["text"].as_s + end else - title = title_container["runs"][0]["text"].as_s + title = "" end - else - title = "" - end - url = item_contents["endpoint"]?.try &.["commandMetadata"]["webCommandMetadata"]["url"].as_s + url = item_contents["endpoint"]?.try &.["commandMetadata"]["webCommandMetadata"]["url"].as_s - # Sometimes a category can have badges. - badges = [] of Tuple(String, String) # (Badge style, label) - item_contents["badges"]?.try &.as_a.each do |badge| - badge = badge["metadataBadgeRenderer"] - badges << {badge["style"].as_s, badge["label"].as_s} - end + # Sometimes a category can have badges. + badges = [] of Tuple(String, String) # (Badge style, label) + item_contents["badges"]?.try &.as_a.each do |badge| + badge = badge["metadataBadgeRenderer"] + badges << {badge["style"].as_s, badge["label"].as_s} + end - # Category description - description_html = item_contents["subtitle"]?.try { |desc| parse_content(desc) } || "" + # Category description + description_html = item_contents["subtitle"]?.try { |desc| parse_content(desc) } || "" - # Content parsing - contents = [] of SearchItem + # Content parsing + contents = [] of SearchItem - # Content could be in three locations. - if content_container = item_contents["content"]["horizontalListRenderer"]? - elsif content_container = item_contents["content"]["expandedShelfContentsRenderer"]? - elsif content_container = item_contents["content"]["verticalListRenderer"]? - else - content_container = item_contents["contents"] - end + # Content could be in three locations. + if content_container = item_contents["content"]["horizontalListRenderer"]? + elsif content_container = item_contents["content"]["expandedShelfContentsRenderer"]? + elsif content_container = item_contents["content"]["verticalListRenderer"]? + else + content_container = item_contents["contents"] + end - raw_contents = content_container["items"].as_a - raw_contents.each do |item| - result = extract_item(item) - if !result.nil? - contents << result + raw_contents = content_container["items"].as_a + raw_contents.each do |item| + result = extract_item(item) + if !result.nil? + contents << result + end end - end - Category.new({ - title: title, - contents: contents, - description_html: description_html, - url: url, - badges: badges, - }) + Category.new({ + title: title, + contents: contents, + description_html: description_html, + url: url, + badges: badges, + }) + end end end @@ -262,88 +250,82 @@ end # a structure we can more easily use via the parsers above. Their internals are # identical to the item parsers. -private abstract struct ItemsContainerExtractor - def process(item : Hash(String, JSON::Any)) - end - - private def extract(target : JSON::Any) - end -end - -private struct YoutubeTabsExtractor < ItemsContainerExtractor - def process(initial_data) - if target = initial_data["twoColumnBrowseResultsRenderer"]? - self.extract(target) +private module Extractors + module YouTubeTabs + def self.process(initial_data : Hash(String, JSON::Any)) + if target = initial_data["twoColumnBrowseResultsRenderer"]? + self.extract(target) + end end - end - - private def extract(target) - raw_items = [] of JSON::Any - selected_tab = extract_selected_tab(target["tabs"]) - content = selected_tab["content"] - - content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| - renderer_container = renderer_container["itemSectionRenderer"] - renderer_container_contents = renderer_container["contents"].as_a[0] - # Category extraction - if items_container = renderer_container_contents["shelfRenderer"]? - raw_items << renderer_container_contents - next - elsif items_container = renderer_container_contents["gridRenderer"]? - else - items_container = renderer_container_contents - end + private def self.extract(target) + raw_items = [] of JSON::Any + selected_tab = extract_selected_tab(target["tabs"]) + content = selected_tab["content"] + + content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| + renderer_container = renderer_container["itemSectionRenderer"] + renderer_container_contents = renderer_container["contents"].as_a[0] + + # Category extraction + if items_container = renderer_container_contents["shelfRenderer"]? + raw_items << renderer_container_contents + next + elsif items_container = renderer_container_contents["gridRenderer"]? + else + items_container = renderer_container_contents + end - items_container["items"].as_a.each do |item| - raw_items << item + items_container["items"].as_a.each do |item| + raw_items << item + end end - end - - return raw_items - end -end -private struct SearchResultsExtractor < ItemsContainerExtractor - def process(initial_data) - if target = initial_data["twoColumnSearchResultsRenderer"]? - self.extract(target) + return raw_items end end - private def extract(target) - raw_items = [] of Array(JSON::Any) - content = target["primaryContents"] - renderer = content["sectionListRenderer"]["contents"].as_a.each do |node| - if node = node["itemSectionRenderer"]? - raw_items << node["contents"].as_a + module SearchResults + def self.process(initial_data : Hash(String, JSON::Any)) + if target = initial_data["twoColumnSearchResultsRenderer"]? + self.extract(target) end end - raw_items = raw_items.flatten + private def self.extract(target) + raw_items = [] of Array(JSON::Any) + content = target["primaryContents"] + renderer = content["sectionListRenderer"]["contents"].as_a.each do |node| + if node = node["itemSectionRenderer"]? + raw_items << node["contents"].as_a + end + end - return raw_items - end -end + raw_items = raw_items.flatten -private struct ContinuationExtractor < ItemsContainerExtractor - def process(initial_data) - if target = initial_data["continuationContents"]? - self.extract(target) - elsif target = initial_data["appendContinuationItemsAction"]? - self.extract(target) + return raw_items end end - private def extract(target) - raw_items = [] of JSON::Any - if content = target["gridContinuation"]? - raw_items = content["items"].as_a - elsif content = target["continuationItems"]? - raw_items = content.as_a + module Continuation + def self.process(initial_data : Hash(String, JSON::Any)) + if target = initial_data["continuationContents"]? + self.extract(target) + elsif target = initial_data["appendContinuationItemsAction"]? + self.extract(target) + end end - return raw_items + private def self.extract(target) + raw_items = [] of JSON::Any + if content = target["gridContinuation"]? + raw_items = content["items"].as_a + elsif content = target["continuationItems"]? + raw_items = content.as_a + end + + return raw_items + end end end -- cgit v1.2.3 From ca9eb0d5392743cd64c9e0c010ae9c507699bc7c Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 3 Aug 2021 21:22:34 -0700 Subject: Bountiful extractor changes - Add extract_text to simplify extraction of InnerTube texts - Add helper extractor methods to reduce repetition in parsing InnerTube - Change [] more than 2 blocks long to use #dig or #dig? - Remove useless ?.try blocks for items that always exists - Add (some) documentation to VideoRendererParser --- src/invidious/helpers/extractors.cr | 178 +++++++++++++++++++++++++----------- 1 file changed, 127 insertions(+), 51 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index cec0e728..dc46d40a 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -32,24 +32,49 @@ private module Parsers private def self.parse(item_contents, author_fallback) video_id = item_contents["videoId"].as_s - title = item_contents["title"].try { |t| t["simpleText"]?.try &.as_s || t["runs"]?.try &.as_a.map(&.["text"].as_s).join("") } || "" + title = extract_text(item_contents["title"]) || "" + # Extract author information author_info = item_contents["ownerText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? - author = author_info.try &.["text"].as_s || author_fallback.name || "" - author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_fallback.id || "" + if author_info = item_contents.dig?("ownerText", "runs") + author_info = author_info[0] + author = author_info["text"].as_s + author_id = HelperExtractors.get_browse_endpoint(author_info) + else + author = author_fallback.name || "" + author_id = author_fallback.id || "" + end - published = item_contents["publishedTimeText"]?.try &.["simpleText"]?.try { |t| decode_date(t.as_s) } || Time.local - view_count = item_contents["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 + # For live videos (and possibly recently premiered videos) there is no published information. + # Instead, in its place is the amount of people currently watching. This behavior should be replicated + # on Invidious once all features of livestreams are supported. On an unrelated note, defaulting to the current + # time for publishing isn't a good idea. + published = item_contents["publishedTimeText"]?.try &.["simpleText"].try { |t| decode_date(t.as_s) } || Time.local + + # Typically views are stored under a "simpleText" in the "viewCountText". However, for + # livestreams and premiered it is stored under a "runs" array: [{"text":123}, {"text": "watching"}] + # When view count is disabled the "viewCountText" is not present on InnerTube data. + # TODO change default value to nil and typical encoding type to tuple storing type (watchers, views, etc) + # and count + view_count = item_contents.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" - length_seconds = item_contents["lengthText"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || - item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?).try &.["thumbnailOverlayTimeStatusRenderer"]? - .try &.["text"]?.try &.["simpleText"]?.try &.as_s.try { |t| decode_length_seconds(t) } || 0 + + # The length information *should* only always exist in "lengthText". However, the legacy Invidious code + # extracts from "thumbnailOverlays" when it doesn't. More testing is needed to see if this is + # actually needed + if length_container = item_contents["lengthText"]? + length_seconds = decode_length_seconds(length_container["simpleText"].as_s) + elsif length_container = item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?) + length_seconds = extract_text(length_container["thumbnailOverlayTimeStatusRenderer"]["text"]).try { |t| decode_length_seconds(t) } || 0 + else + length_seconds = 0 + end live_now = false paid = false premium = false - premiere_timestamp = item_contents["upcomingEventData"]?.try &.["startTime"]?.try { |t| Time.unix(t.as_s.to_i64) } + premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } item_contents["badges"]?.try &.as_a.each do |badge| b = badge["metadataBadgeRenderer"] @@ -89,15 +114,17 @@ private module Parsers end private def self.parse(item_contents, author_fallback) - author = item_contents["title"]["simpleText"]?.try &.as_s || author_fallback.name || "" + author = extract_text(item_contents["title"]) || author_fallback.name || "" author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id || "" - author_thumbnail = item_contents["thumbnail"]["thumbnails"]?.try &.as_a[0]?.try &.["url"]?.try &.as_s || "" - subscriber_count = item_contents["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s.try { |s| short_text_to_number(s.split(" ")[0]) } || 0 + author_thumbnail = HelperExtractors.get_thumbnails(item_contents) + # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. + # TODO change default value to nil + subscriber_count = item_contents.dig?("subscriberCountText").try &.["simpleText"].try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 - auto_generated = false - auto_generated = true if !item_contents["videoCountText"]? - video_count = item_contents["videoCountText"]?.try &.["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 + auto_generated = !item_contents["videoCountText"]? ? true : false + + video_count = HelperExtractors.get_video_count(item_contents) description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" SearchChannel.new({ @@ -120,11 +147,11 @@ private module Parsers end private def self.parse(item_contents, author_fallback) - title = item_contents["title"]["runs"].as_a[0]?.try &.["text"].as_s || "" + title = extract_text(item_contents["title"]) || "" plid = item_contents["playlistId"]?.try &.as_s || "" - video_count = item_contents["videoCountText"]["runs"].as_a[0]?.try &.["text"].as_s.gsub(/\D/, "").to_i || 0 - playlist_thumbnail = item_contents["thumbnail"]["thumbnails"][0]?.try &.["url"]?.try &.as_s || "" + video_count = HelperExtractors.get_video_count(item_contents) + playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents) SearchPlaylist.new({ title: title, @@ -141,26 +168,26 @@ private module Parsers module PlaylistRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = item["playlistRenderer"]? - return self.parse(item_contents, author_fallback) + return self.parse(item_contents) end end - private def self.parse(item_contents, author_fallback) + private def self.parse(item_contents) title = item_contents["title"]["simpleText"]?.try &.as_s || "" plid = item_contents["playlistId"]?.try &.as_s || "" - video_count = item_contents["videoCount"]?.try &.as_s.to_i || 0 - playlist_thumbnail = item_contents["thumbnails"].as_a[0]?.try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"].as_s || "" + video_count = HelperExtractors.get_video_count(item_contents) + playlist_thumbnail = HelperExtractors.get_thumbnails_plural(item_contents) - author_info = item_contents["shortBylineText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? - author = author_info.try &.["text"].as_s || author_fallback.name || "" - author_id = author_info.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]["browseId"].as_s || author_fallback.id || "" + author_info = item_contents.dig("shortBylineText", "runs", 0) + author = author_info["text"].as_s + author_id = HelperExtractors.get_browse_endpoint(author_info) videos = item_contents["videos"]?.try &.as_a.map do |v| v = v["childVideoRenderer"] - v_title = v["title"]["simpleText"]?.try &.as_s || "" + v_title = v.dig?("title", "simpleText").try &.as_s || "" v_id = v["videoId"]?.try &.as_s || "" - v_length_seconds = v["lengthText"]?.try &.["simpleText"]?.try { |t| decode_length_seconds(t.as_s) } || 0 + v_length_seconds = v.dig?("lengthText", "simpleText").try { |t| decode_length_seconds(t.as_s) } || 0 SearchPlaylistVideo.new({ title: v_title, id: v_id, @@ -190,20 +217,8 @@ private module Parsers end private def self.parse(item_contents, author_fallback) - # Title extraction is a bit complicated. There are two possible routes for it - # as well as times when the title attribute just isn't sent by YT. - title_container = item_contents["title"]? || "" - if !title_container.is_a? String - if title = title_container["simpleText"]? - title = title.as_s - else - title = title_container["runs"][0]["text"].as_s - end - else - title = "" - end - - url = item_contents["endpoint"]?.try &.["commandMetadata"]["webCommandMetadata"]["url"].as_s + title = extract_text(item_contents["title"]?) || "" + url = item_contents["endpoint"]?.try &.dig("commandMetadata", "webCommandMetadata", "url").as_s # Sometimes a category can have badges. badges = [] of Tuple(String, String) # (Badge style, label) @@ -249,7 +264,6 @@ end # the internal Youtube API's JSON response. The result is then packaged into # a structure we can more easily use via the parsers above. Their internals are # identical to the item parsers. - private module Extractors module YouTubeTabs def self.process(initial_data : Hash(String, JSON::Any)) @@ -260,12 +274,10 @@ private module Extractors private def self.extract(target) raw_items = [] of JSON::Any - selected_tab = extract_selected_tab(target["tabs"]) - content = selected_tab["content"] + content = extract_selected_tab(target["tabs"])["content"] content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| - renderer_container = renderer_container["itemSectionRenderer"] - renderer_container_contents = renderer_container["contents"].as_a[0] + renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"].as_a[0] # Category extraction if items_container = renderer_container_contents["shelfRenderer"]? @@ -294,16 +306,14 @@ private module Extractors private def self.extract(target) raw_items = [] of Array(JSON::Any) - content = target["primaryContents"] - renderer = content["sectionListRenderer"]["contents"].as_a.each do |node| + + target.dig("primaryContents", "sectionListRenderer", "contents").as_a.each do |node| if node = node["itemSectionRenderer"]? raw_items << node["contents"].as_a end end - raw_items = raw_items.flatten - - return raw_items + return raw_items.flatten end end @@ -329,6 +339,72 @@ private module Extractors end end +# Helper methods to extract out certain stuff from InnerTube +private module HelperExtractors + # Retrieves the amount of videos present within the given InnerTube data. + # + # Returns a 0 when it's unable to do so + def self.get_video_count(container : JSON::Any) : Int32 + if box = container["videoCountText"]? + return extract_text(container["videoCountText"]?).try &.gsub(/\D/, "").to_i || 0 + elsif box = container["videoCount"]? + return box.as_s.to_i + else + return 0 + end + end + + # Retrieve lowest quality thumbnail from InnerTube data + # + # TODO allow configuration of image quality (-1 is highest) + # + # Raises when it's unable to parse from the given JSON data. + def self.get_thumbnails(container : JSON::Any) : String + return container.dig("thumbnail", "thumbnails", 0, "url").as_s + end + + # ditto + # YouTube sometimes sends the thumbnail as: + # {"thumbnails": [{"thumbnails": [{"url": "example.com"}, ...]}]} + def self.get_thumbnails_plural(container : JSON::Any) : String + return container.dig("thumbnails", 0, "thumbnails", 0, "url").as_s + end + + # Retrieves the ID required for querying the InnerTube browse endpoint + # + # Raises when it's unable to do so + def self.get_browse_endpoint(container) + return container.dig("navigationEndpoint", "browseEndpoint", "browseId").as_s + end +end + +# Extracts text from InnerTube response +# +# InnerTube can package text in three different formats +# "runs": [ +# {"text": "something"}, +# {"text": "cont"}, +# ... +# ] +# +# "SimpleText": "something" +# +# Or sometimes just none at all as with the data returned from +# category continuations. +def extract_text(item : JSON::Any?) : String? + if item.nil? + return nil + end + + if text_container = item["simpleText"]? + return text_container.as_s + elsif text_container = item["runs"]? + return text_container.as_a.map(&.["text"].as_s).join("") + else + nil + end +end + # Parses an item from Youtube's JSON response into a more usable structure. # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. def extract_item(item : JSON::Any, author_fallback : String? = nil, -- cgit v1.2.3 From e5f07dedbf92459a237165f359d7565e638d4ffa Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 4 Aug 2021 19:54:41 -0700 Subject: Typos and tiny styling changes --- src/invidious/helpers/extractors.cr | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index dc46d40a..3a90f017 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -35,9 +35,7 @@ private module Parsers title = extract_text(item_contents["title"]) || "" # Extract author information - author_info = item_contents["ownerText"]?.try &.["runs"]?.try &.as_a?.try &.[0]? - if author_info = item_contents.dig?("ownerText", "runs") - author_info = author_info[0] + if author_info = item_contents.dig?("ownerText", "runs", 0) author = author_info["text"].as_s author_id = HelperExtractors.get_browse_endpoint(author_info) else @@ -49,7 +47,7 @@ private module Parsers # Instead, in its place is the amount of people currently watching. This behavior should be replicated # on Invidious once all features of livestreams are supported. On an unrelated note, defaulting to the current # time for publishing isn't a good idea. - published = item_contents["publishedTimeText"]?.try &.["simpleText"].try { |t| decode_date(t.as_s) } || Time.local + published = item_contents.dig?("publishedTimeText", "simpleText").try { |t| decode_date(t.as_s) } || Time.local # Typically views are stored under a "simpleText" in the "viewCountText". However, for # livestreams and premiered it is stored under a "runs" array: [{"text":123}, {"text": "watching"}] @@ -119,8 +117,10 @@ private module Parsers author_thumbnail = HelperExtractors.get_thumbnails(item_contents) # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. + # Always simpleText # TODO change default value to nil - subscriber_count = item_contents.dig?("subscriberCountText").try &.["simpleText"].try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 + subscriber_count = item_contents.dig?("subscriberCountText").try &.["simpleText"].try { \ + |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 auto_generated = !item_contents["videoCountText"]? ? true : false @@ -420,10 +420,9 @@ def extract_item(item : JSON::Any, author_fallback : String? = nil, return result end end - # TODO radioRenderer, showRenderer, shelfRenderer, horizontalCardListRenderer, searchPyvRenderer end -# Parses multiple items from Youtube's initial JSON response into a more usable structure. +# Parses multiple items from YouTube's initial JSON response into a more usable structure. # The end result is an array of SearchItem. def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) : Array(SearchItem) @@ -436,7 +435,7 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri unpackaged_data = initial_data end - # This is identicial to the parser cyling of extract_item(). + # This is identical to the parser cyling of extract_item(). ITEM_CONTAINER_EXTRACTOR.each do |extractor| results = extractor.process(unpackaged_data) if !results.nil? -- cgit v1.2.3 From 092b8a4e5220cbe7e6eed45d1c331d5596dc68bc Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 5 Aug 2021 20:31:48 -0700 Subject: Add documentation to extractors.cr --- src/invidious/helpers/extractors.cr | 122 +++++++++++++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 3a90f017..32134cc9 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -18,11 +18,22 @@ private ITEM_PARSERS = { record AuthorFallback, name : String? = nil, id : String? = nil -# The following are the parsers for parsing raw item data into neatly packaged structs. -# They're accessed through the process() method which validates the given data as applicable -# to their specific struct and then use the internal parse() method to assemble the struct -# specific to their category. +# Namespace for logic relating to parsing InnerTube data into various datastructs. +# +# Each of the parsers in this namespace are accessed through the #process() method +# which validates the given data as applicable to itself. If it is applicable the given +# data is passed to the private `#parse()` method which returns a datastruct of the given +# type. Otherwise, nil is returned. private module Parsers + # Parses a InnerTube videoRenderer into a SearchVideo. Returns nil when the given object isn't a videoRenderer + # + # A videoRenderer renders a video to click on within the YouTube and Invidious UI. It is **not** + # the watchable video itself. + # + # See specs for example. + # + # `videoRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. + # module VideoRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?) @@ -104,6 +115,15 @@ private module Parsers end end + # Parses a InnerTube channelRenderer into a SearchChannel. Returns nil when the given object isn't a channelRenderer + # + # A channelRenderer renders a channel to click on within the YouTube and Invidious UI. It is **not** + # the channel page itself. + # + # See specs for example. + # + # `channelRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. + # module ChannelRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?) @@ -139,6 +159,15 @@ private module Parsers end end + # Parses a InnerTube gridPlaylistRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridPlaylistRenderer + # + # A gridPlaylistRenderer renders a playlist, that is located in a grid, to click on within the YouTube and Invidious UI. + # It is **not** the playlist itself. + # + # See specs for example. + # + # `gridPlaylistRenderer`s can be found on the playlist-tabs of channels and expanded categories. + # module GridPlaylistRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = item["gridPlaylistRenderer"]? @@ -165,6 +194,14 @@ private module Parsers end end + # Parses a InnerTube playlistRenderer into a SearchPlaylist. Returns nil when the given object isn't a playlistRenderer + # + # A playlistRenderer renders a playlist to click on within the YouTube and Invidious UI. It is **not** the playlist itself. + # + # See specs for example. + # + # `playlistRenderer`s can be found almost everywhere on YouTube. In categories, search results, recommended, etc. + # module PlaylistRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = item["playlistRenderer"]? @@ -209,6 +246,16 @@ private module Parsers end end + # Parses a InnerTube shelfRenderer into a Category. Returns nil when the given object isn't a shelfRenderer + # + # A shelfRenderer renders divided sections on YouTube. IE "People also watched" in search results and + # the various organizational sections in the channel home page. A separate one (richShelfRenderer) is used + # for YouTube home. A shelfRenderer can also sometimes be expanded to show more content within it. + # + # See specs for example. + # + # `shelfRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. + # module CategoryRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = item["shelfRenderer"]? @@ -264,7 +311,34 @@ end # the internal Youtube API's JSON response. The result is then packaged into # a structure we can more easily use via the parsers above. Their internals are # identical to the item parsers. + +# Namespace for logic relating to extracting InnerTube's initial response to items we can parse. +# +# Each of the extractors in this namespace are accessed through the #process() method +# which validates the given data as applicable to itself. If it is applicable the given +# data is passed to the private `#extract()` method which returns an array of +# parsable items. Otherwise, nil is returned. +# +# NOTE perhaps the result from here should be abstracted into a struct in order to +# get additional metadata regarding the container of the item(s). private module Extractors + # Extracts items from the selected YouTube tab. + # + # YouTube tabs are typically stored under "twoColumnBrowseResultsRenderer" + # and is structured like this: + # + # "twoColumnBrowseResultsRenderer": { + # {"tabs": [ + # {"tabRenderer": { + # "endpoint": {...} + # "title": "Playlists", + # "selected": true, + # "content": {...}, + # ... + # }} + # ]} + # }] + # module YouTubeTabs def self.process(initial_data : Hash(String, JSON::Any)) if target = initial_data["twoColumnBrowseResultsRenderer"]? @@ -297,6 +371,23 @@ private module Extractors end end + # Extracts items from the InnerTube response for search results + # + # Search results are typically stored under "twoColumnSearchResultsRenderer" + # and is structured like this: + # + # "twoColumnSearchResultsRenderer": { + # {"primaryContents": { + # {"sectionListRenderer": { + # "contents": [...], + # ..., + # "subMenu": {...}, + # "hideBottomSeparator": true, + # "targetId": "search-feed" + # }} + # }} + # } + # module SearchResults def self.process(initial_data : Hash(String, JSON::Any)) if target = initial_data["twoColumnSearchResultsRenderer"]? @@ -317,6 +408,16 @@ private module Extractors end end + # Extracts continuation items from a InnerTube response + # + # Continuation items (on YouTube) are items which are appended to the + # end of the page for continuous scrolling. As such, in many cases, + # the items are lacking information such as author or category title, + # since the original results has already rendered them on the top of the page. + # + # The way they are structured is too varied to be accurately written down here. + # However, they all eventually lead to an array of parsable items after traversing + # through the JSON structure. module Continuation def self.process(initial_data : Hash(String, JSON::Any)) if target = initial_data["continuationContents"]? @@ -339,7 +440,10 @@ private module Extractors end end -# Helper methods to extract out certain stuff from InnerTube +# Helper methods to aid in the parsing of InnerTube to data structs. +# +# Mostly used to extract out repeated structures to deal with code +# repetition. private module HelperExtractors # Retrieves the amount of videos present within the given InnerTube data. # @@ -364,14 +468,14 @@ private module HelperExtractors end # ditto + # # YouTube sometimes sends the thumbnail as: # {"thumbnails": [{"thumbnails": [{"url": "example.com"}, ...]}]} def self.get_thumbnails_plural(container : JSON::Any) : String return container.dig("thumbnails", 0, "thumbnails", 0, "url").as_s end - # Retrieves the ID required for querying the InnerTube browse endpoint - # + # Retrieves the ID required for querying the InnerTube browse endpoint. # Raises when it's unable to do so def self.get_browse_endpoint(container) return container.dig("navigationEndpoint", "browseEndpoint", "browseId").as_s @@ -391,6 +495,10 @@ end # # Or sometimes just none at all as with the data returned from # category continuations. +# +# In order to facilitate calling this function with `#[]?`: +# A nil will be accepted. Of course, since nil cannot be parsed, +# another nil will be returned. def extract_text(item : JSON::Any?) : String? if item.nil? return nil -- cgit v1.2.3 From b20f72b9638e33c5a0417d825cdc5c4af7e855c6 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Sun, 26 Sep 2021 21:03:45 +0000 Subject: Use default timeout (5 seconds) for YT pool (#2430) --- src/invidious.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index fa585435..9229c9d1 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -67,7 +67,7 @@ SOFTWARE = { "branch" => "#{CURRENT_BRANCH}", } -YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, timeout: 2.0, use_quic: CONFIG.use_quic) +YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, use_quic: CONFIG.use_quic) # CLI Kemal.config.extra_options do |parser| -- cgit v1.2.3 From 9ba3e1cdb45cc4eafc33c02e773aaee286aac551 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 26 Sep 2021 23:31:14 +0200 Subject: Decrease channel refresh frequency (1 min -> 1 h) This is a temporary fix to reduce load on instances with many channels and avoid IP being flagged by Google. --- src/invidious/jobs/refresh_channels_job.cr | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index fbe6d381..0adb4b5a 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -58,8 +58,9 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob end end - LOGGER.debug("RefreshChannelsJob: Done, sleeping for one minute") - sleep 1.minute + # TODO: make this configurable + LOGGER.debug("RefreshChannelsJob: Done, sleeping for one hour") + sleep 1.hour Fiber.yield end end -- cgit v1.2.3 From 6df85718e6dac2faa9037fcf2283aa6b5ab819a3 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Tue, 28 Sep 2021 15:23:36 +0000 Subject: Apply suggestions from code review Co-authored-by: Samantaz Fox --- src/invidious/helpers/extractors.cr | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 32134cc9..0c645868 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -139,8 +139,8 @@ private module Parsers # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. # Always simpleText # TODO change default value to nil - subscriber_count = item_contents.dig?("subscriberCountText").try &.["simpleText"].try { \ - |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 + subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") + .try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 auto_generated = !item_contents["videoCountText"]? ? true : false @@ -265,7 +265,8 @@ private module Parsers private def self.parse(item_contents, author_fallback) title = extract_text(item_contents["title"]?) || "" - url = item_contents["endpoint"]?.try &.dig("commandMetadata", "webCommandMetadata", "url").as_s + url = item_contents.dig?("endpoint", "commandMetadata", "webCommandMetadata", "url") + .try &.as_s # Sometimes a category can have badges. badges = [] of Tuple(String, String) # (Badge style, label) @@ -450,7 +451,7 @@ private module HelperExtractors # Returns a 0 when it's unable to do so def self.get_video_count(container : JSON::Any) : Int32 if box = container["videoCountText"]? - return extract_text(container["videoCountText"]?).try &.gsub(/\D/, "").to_i || 0 + return extract_text(box).try &.gsub(/\D/, "").to_i || 0 elsif box = container["videoCount"]? return box.as_s.to_i else -- cgit v1.2.3 From 43ea8fa70698ef94701fdf9da419300b9a6a0710 Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 28 Sep 2021 08:19:55 -0700 Subject: Convert nil for AuthorFallback to empty strings --- src/invidious/helpers/extractors.cr | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 0c645868..88248e8d 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -16,7 +16,7 @@ private ITEM_PARSERS = { Parsers::CategoryRendererParser, } -record AuthorFallback, name : String? = nil, id : String? = nil +record AuthorFallback, name : String, id : String # Namespace for logic relating to parsing InnerTube data into various datastructs. # @@ -50,8 +50,8 @@ private module Parsers author = author_info["text"].as_s author_id = HelperExtractors.get_browse_endpoint(author_info) else - author = author_fallback.name || "" - author_id = author_fallback.id || "" + author = author_fallback.name + author_id = author_fallback.id end # For live videos (and possibly recently premiered videos) there is no published information. @@ -132,8 +132,8 @@ private module Parsers end private def self.parse(item_contents, author_fallback) - author = extract_text(item_contents["title"]) || author_fallback.name || "" - author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id || "" + author = extract_text(item_contents["title"]) || author_fallback.name + author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id author_thumbnail = HelperExtractors.get_thumbnails(item_contents) # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. @@ -185,8 +185,8 @@ private module Parsers SearchPlaylist.new({ title: title, id: plid, - author: author_fallback.name || "", - ucid: author_fallback.id || "", + author: author_fallback.name, + ucid: author_fallback.id, video_count: video_count, videos: [] of SearchPlaylistVideo, thumbnail: playlist_thumbnail, @@ -516,9 +516,12 @@ end # Parses an item from Youtube's JSON response into a more usable structure. # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. -def extract_item(item : JSON::Any, author_fallback : String? = nil, - author_id_fallback : String? = nil) - author_fallback = AuthorFallback.new(author_fallback, author_id_fallback) +def extract_item(item : JSON::Any, author_fallback : String? = "", + author_id_fallback : String? = "") + # We "allow" nil values but secretly use empty strings instead. This is to save us the + # hassle of modifying every author_fallback and author_id_fallback arg usage + # which is more often than not nil. + author_fallback = AuthorFallback.new(author_fallback || "", author_id_fallback || "") # Cycles through all of the item parsers and attempt to parse the raw YT JSON data. # Each parser automatically validates the data given to see if the data is -- cgit v1.2.3 From aa59925374849a4e2aee09de5e65ba027e16f3be Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 28 Sep 2021 08:39:00 -0700 Subject: Rename get_browse_endpoint to get_browse_id --- src/invidious/helpers/extractors.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 88248e8d..13ffe1e4 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -48,7 +48,7 @@ private module Parsers # Extract author information if author_info = item_contents.dig?("ownerText", "runs", 0) author = author_info["text"].as_s - author_id = HelperExtractors.get_browse_endpoint(author_info) + author_id = HelperExtractors.get_browse_id(author_info) else author = author_fallback.name author_id = author_fallback.id @@ -218,7 +218,7 @@ private module Parsers author_info = item_contents.dig("shortBylineText", "runs", 0) author = author_info["text"].as_s - author_id = HelperExtractors.get_browse_endpoint(author_info) + author_id = HelperExtractors.get_browse_id(author_info) videos = item_contents["videos"]?.try &.as_a.map do |v| v = v["childVideoRenderer"] @@ -478,7 +478,7 @@ private module HelperExtractors # Retrieves the ID required for querying the InnerTube browse endpoint. # Raises when it's unable to do so - def self.get_browse_endpoint(container) + def self.get_browse_id(container) return container.dig("navigationEndpoint", "browseEndpoint", "browseId").as_s end end -- cgit v1.2.3 From 9ab242ca2e79ecc8a196a019619fa3ddab31b28a Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 28 Sep 2021 08:50:23 -0700 Subject: Optimize routing logic of extract_item(s) funcs --- src/invidious/helpers/extractors.cr | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 13ffe1e4..c6929162 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -352,7 +352,7 @@ private module Extractors content = extract_selected_tab(target["tabs"])["content"] content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| - renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"].as_a[0] + renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] # Category extraction if items_container = renderer_container_contents["shelfRenderer"]? @@ -527,8 +527,7 @@ def extract_item(item : JSON::Any, author_fallback : String? = "", # Each parser automatically validates the data given to see if the data is # applicable to itself. If not nil is returned and the next parser is attemped. ITEM_PARSERS.each do |parser| - result = parser.process(item, author_fallback) - if !result.nil? + if result = parser.process(item, author_fallback) return result end end @@ -542,22 +541,21 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri if unpackaged_data = initial_data["contents"]?.try &.as_h elsif unpackaged_data = initial_data["response"]?.try &.as_h - elsif unpackaged_data = initial_data["onResponseReceivedActions"]?.try &.as_a.[0].as_h + elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h else unpackaged_data = initial_data end - # This is identical to the parser cyling of extract_item(). + # This is identical to the parser cycling of extract_item(). ITEM_CONTAINER_EXTRACTOR.each do |extractor| - results = extractor.process(unpackaged_data) - if !results.nil? - results.each do |item| - parsed_result = extract_item(item, author_fallback, author_id_fallback) - - if !parsed_result.nil? + if container = extractor.process(unpackaged_data) + # Extract items in container + container.each do |item| + if parsed_result = extract_item(item, author_fallback, author_id_fallback) items << parsed_result end end + return items end end -- cgit v1.2.3 From 23049e026f4c4f8fe02f8a911a717791345d44fa Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 28 Sep 2021 08:55:02 -0700 Subject: Improve readabltiy of SearchChannel auto-gen detect --- src/invidious/helpers/extractors.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index c6929162..83c751e0 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -142,7 +142,9 @@ private module Parsers subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") .try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 - auto_generated = !item_contents["videoCountText"]? ? true : false + # Auto-generated channels doesn't have videoCountText + # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922 + auto_generated = item_contents["videoCountText"]?.nil? video_count = HelperExtractors.get_video_count(item_contents) description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" -- cgit v1.2.3 From 26b28cea498f3d7be10907165e1f9d8322843911 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 1 Oct 2021 05:39:23 -0700 Subject: Use break instead of short-circuit return --- src/invidious/helpers/extractors.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 83c751e0..850c93ec 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -558,7 +558,7 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri end end - return items + break end end -- cgit v1.2.3 From 7b2aa5f98e140ae989eb4f61cd8a94e3127de025 Mon Sep 17 00:00:00 2001 From: mastihios <91783447+mastihios@users.noreply.github.com> Date: Sat, 2 Oct 2021 11:59:33 +0000 Subject: add icon-buttons to playlist items (#2442) --- src/invidious/views/components/item.ecr | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'src') diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 68aa1812..3391bd17 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -79,6 +79,19 @@ +
-- cgit v1.2.3 From 77131cff91c2ac12488ff43524e2e2a09a11b229 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Sat, 2 Oct 2021 15:16:07 +0000 Subject: Use make_client when querying instance api --- src/invidious/helpers/utils.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 6ee07d7a..68ba76f9 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -412,7 +412,7 @@ end def fetch_random_instance begin - instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io")) + instance_api_client = make_client(URI.parse("https://api.invidious.io")) # Timeouts instance_api_client.connect_timeout = 10.seconds -- cgit v1.2.3 From cd02078e26c0ee9ea64e418426a7dea76770d584 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 2 Oct 2021 17:19:28 +0200 Subject: Add Portuguese (pt) to locales follow up to #2437 --- src/invidious/helpers/i18n.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 7ffdfdcc..a65ae00b 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -24,6 +24,7 @@ LOCALES = { "nb-NO" => load_locale("nb-NO"), # Norwegian Bokmål "nl" => load_locale("nl"), # Dutch "pl" => load_locale("pl"), # Polish + "pt" => load_locale("pt"), # Portuguese "pt-BR" => load_locale("pt-BR"), # Portuguese (Brazil) "pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal) "ro" => load_locale("ro"), # Romanian -- cgit v1.2.3 From caa08a6379116703b510c457dc5427e1c9b1735a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 2 Oct 2021 17:38:06 +0200 Subject: Disable locales with less than 50% of strings translated --- src/invidious/helpers/i18n.cr | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index a65ae00b..a90f3f19 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,6 +1,6 @@ LOCALES = { "ar" => load_locale("ar"), # Arabic - "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) + # "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) [Incomplete] "cs" => load_locale("cs"), # Czech "da" => load_locale("da"), # Danish "de" => load_locale("de"), # German @@ -8,7 +8,7 @@ LOCALES = { "en-US" => load_locale("en-US"), # English (US) "eo" => load_locale("eo"), # Esperanto "es" => load_locale("es"), # Spanish - "eu" => load_locale("eu"), # Basque + # "eu" => load_locale("eu"), # Basque [Incomplete] "fa" => load_locale("fa"), # Persian "fi" => load_locale("fi"), # Finnish "fr" => load_locale("fr"), # French @@ -29,10 +29,10 @@ LOCALES = { "pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal) "ro" => load_locale("ro"), # Romanian "ru" => load_locale("ru"), # Russian - "si" => load_locale("si"), # Sinhala - "sk" => load_locale("sk"), # Slovak - "sr" => load_locale("sr"), # Serbian - "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic) + # "si" => load_locale("si"), # Sinhala [Incomplete] + # "sk" => load_locale("sk"), # Slovak [Incomplete] + # "sr" => load_locale("sr"), # Serbian [Incomplete] + # "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic) [Incomplete] "sv-SE" => load_locale("sv-SE"), # Swedish "tr" => load_locale("tr"), # Turkish "uk" => load_locale("uk"), # Ukrainian -- cgit v1.2.3 From 5a52b4fe45e6b680c75d32eaa6ae0ff185c37b63 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 2 Oct 2021 17:41:25 +0200 Subject: Fix lint --- src/invidious/helpers/i18n.cr | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index a90f3f19..9752b646 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,6 +1,11 @@ +# "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) [Incomplete] +# "eu" => load_locale("eu"), # Basque [Incomplete] +# "si" => load_locale("si"), # Sinhala [Incomplete] +# "sk" => load_locale("sk"), # Slovak [Incomplete] +# "sr" => load_locale("sr"), # Serbian [Incomplete] +# "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic) [Incomplete] LOCALES = { "ar" => load_locale("ar"), # Arabic - # "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) [Incomplete] "cs" => load_locale("cs"), # Czech "da" => load_locale("da"), # Danish "de" => load_locale("de"), # German @@ -8,7 +13,6 @@ LOCALES = { "en-US" => load_locale("en-US"), # English (US) "eo" => load_locale("eo"), # Esperanto "es" => load_locale("es"), # Spanish - # "eu" => load_locale("eu"), # Basque [Incomplete] "fa" => load_locale("fa"), # Persian "fi" => load_locale("fi"), # Finnish "fr" => load_locale("fr"), # French @@ -29,10 +33,6 @@ LOCALES = { "pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal) "ro" => load_locale("ro"), # Romanian "ru" => load_locale("ru"), # Russian - # "si" => load_locale("si"), # Sinhala [Incomplete] - # "sk" => load_locale("sk"), # Slovak [Incomplete] - # "sr" => load_locale("sr"), # Serbian [Incomplete] - # "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic) [Incomplete] "sv-SE" => load_locale("sv-SE"), # Swedish "tr" => load_locale("tr"), # Turkish "uk" => load_locale("uk"), # Ukrainian -- cgit v1.2.3 From 73eabb6ca20f15547aa4baeb89bbecdaf6e7aa3b Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 2 Oct 2021 08:53:58 -0700 Subject: Actually fix lint --- src/invidious/helpers/i18n.cr | 68 +++++++++++++++++++++---------------------- 1 file changed, 34 insertions(+), 34 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 9752b646..2ed4f150 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -5,40 +5,40 @@ # "sr" => load_locale("sr"), # Serbian [Incomplete] # "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic) [Incomplete] LOCALES = { - "ar" => load_locale("ar"), # Arabic - "cs" => load_locale("cs"), # Czech - "da" => load_locale("da"), # Danish - "de" => load_locale("de"), # German - "el" => load_locale("el"), # Greek - "en-US" => load_locale("en-US"), # English (US) - "eo" => load_locale("eo"), # Esperanto - "es" => load_locale("es"), # Spanish - "fa" => load_locale("fa"), # Persian - "fi" => load_locale("fi"), # Finnish - "fr" => load_locale("fr"), # French - "he" => load_locale("he"), # Hebrew - "hr" => load_locale("hr"), # Croatian - "hu-HU" => load_locale("hu-HU"), # Hungarian - "id" => load_locale("id"), # Indonesian - "is" => load_locale("is"), # Icelandic - "it" => load_locale("it"), # Italian - "ja" => load_locale("ja"), # Japanese - "ko" => load_locale("ko"), # Korean - "lt" => load_locale("lt"), # Lithuanian - "nb-NO" => load_locale("nb-NO"), # Norwegian Bokmål - "nl" => load_locale("nl"), # Dutch - "pl" => load_locale("pl"), # Polish - "pt" => load_locale("pt"), # Portuguese - "pt-BR" => load_locale("pt-BR"), # Portuguese (Brazil) - "pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal) - "ro" => load_locale("ro"), # Romanian - "ru" => load_locale("ru"), # Russian - "sv-SE" => load_locale("sv-SE"), # Swedish - "tr" => load_locale("tr"), # Turkish - "uk" => load_locale("uk"), # Ukrainian - "vi" => load_locale("vi"), # Vietnamese - "zh-CN" => load_locale("zh-CN"), # Chinese (Simplified) - "zh-TW" => load_locale("zh-TW"), # Chinese (Traditional) + "ar" => load_locale("ar"), # Arabic + "cs" => load_locale("cs"), # Czech + "da" => load_locale("da"), # Danish + "de" => load_locale("de"), # German + "el" => load_locale("el"), # Greek + "en-US" => load_locale("en-US"), # English (US) + "eo" => load_locale("eo"), # Esperanto + "es" => load_locale("es"), # Spanish + "fa" => load_locale("fa"), # Persian + "fi" => load_locale("fi"), # Finnish + "fr" => load_locale("fr"), # French + "he" => load_locale("he"), # Hebrew + "hr" => load_locale("hr"), # Croatian + "hu-HU" => load_locale("hu-HU"), # Hungarian + "id" => load_locale("id"), # Indonesian + "is" => load_locale("is"), # Icelandic + "it" => load_locale("it"), # Italian + "ja" => load_locale("ja"), # Japanese + "ko" => load_locale("ko"), # Korean + "lt" => load_locale("lt"), # Lithuanian + "nb-NO" => load_locale("nb-NO"), # Norwegian Bokmål + "nl" => load_locale("nl"), # Dutch + "pl" => load_locale("pl"), # Polish + "pt" => load_locale("pt"), # Portuguese + "pt-BR" => load_locale("pt-BR"), # Portuguese (Brazil) + "pt-PT" => load_locale("pt-PT"), # Portuguese (Portugal) + "ro" => load_locale("ro"), # Romanian + "ru" => load_locale("ru"), # Russian + "sv-SE" => load_locale("sv-SE"), # Swedish + "tr" => load_locale("tr"), # Turkish + "uk" => load_locale("uk"), # Ukrainian + "vi" => load_locale("vi"), # Vietnamese + "zh-CN" => load_locale("zh-CN"), # Chinese (Simplified) + "zh-TW" => load_locale("zh-TW"), # Chinese (Traditional) } def load_locale(name) -- cgit v1.2.3 From 81b12b800190c7aa40c18cfb12cd39faae62c3df Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Sat, 2 Oct 2021 20:04:02 +0000 Subject: Add support to make invidious API-only via flag (#2363) --- src/invidious.cr | 150 ++++++++++++++++++------------------ src/invidious/routes/channels.cr | 2 + src/invidious/routes/embed.cr | 2 + src/invidious/routes/feeds.cr | 2 + src/invidious/routes/login.cr | 2 + src/invidious/routes/misc.cr | 2 + src/invidious/routes/playlists.cr | 2 + src/invidious/routes/preferences.cr | 2 + src/invidious/routes/search.cr | 2 + src/invidious/routes/watch.cr | 2 + 10 files changed, 94 insertions(+), 74 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 9229c9d1..9e67e216 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -312,80 +312,82 @@ before_all do |env| env.set "current_page", URI.encode_www_form(current_page) end -Invidious::Routing.get "/", Invidious::Routes::Misc, :home -Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy -Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses - -Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home -Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home -Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos -Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists -Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community -Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about - -["", "/videos", "/playlists", "/community", "/about"].each do |path| - # /c/LinusTechTips - Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect - # /user/linustechtips | Not always the same as /c/ - Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect - # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow - Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect - # /profile?user=linustechtips - Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile -end - -Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle -Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect -Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect -Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect -Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect -Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect -Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect - -Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect -Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show - -Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new -Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create -Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe -Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page -Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete -Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit -Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update -Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page -Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax -Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show -Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix - -Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch -Invidious::Routing.get "/results", Invidious::Routes::Search, :results -Invidious::Routing.get "/search", Invidious::Routes::Search, :search - -Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page -Invidious::Routing.post "/login", Invidious::Routes::Login, :login -Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout - -Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show -Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update -Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme - -# Feeds -Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect -Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists -Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular -Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending -Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions -Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history - -# RSS Feeds -Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel -Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private -Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist -Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos - -# Support push notifications via PubSubHubbub -Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get -Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post +{% unless flag?(:api_only) %} + Invidious::Routing.get "/", Invidious::Routes::Misc, :home + Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy + Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses + + Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home + Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home + Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos + Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists + Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community + Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about + + ["", "/videos", "/playlists", "/community", "/about"].each do |path| + # /c/LinusTechTips + Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect + # /user/linustechtips | Not always the same as /c/ + Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect + # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow + Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect + # /profile?user=linustechtips + Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile + end + + Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle + Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect + Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect + Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect + Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect + Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect + Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect + + Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect + Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show + + Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new + Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create + Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe + Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page + Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete + Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit + Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update + Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page + Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax + Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show + Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix + + Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch + Invidious::Routing.get "/results", Invidious::Routes::Search, :results + Invidious::Routing.get "/search", Invidious::Routes::Search, :search + + Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page + Invidious::Routing.post "/login", Invidious::Routes::Login, :login + Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout + + Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show + Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update + Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme + + # Feeds + Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect + Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists + Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular + Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending + Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions + Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history + + # RSS Feeds + Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel + Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private + Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist + Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos + + # Support push notifications via PubSubHubbub + Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get + Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post +{% end %} # API routes (macro) define_v1_api_routes() diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 6a32988e..11c2f869 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -1,3 +1,5 @@ +{% skip_file if flag?(:api_only) %} + module Invidious::Routes::Channels def self.home(env) self.videos(env) diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 5fc8a61f..80d09789 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -1,3 +1,5 @@ +{% skip_file if flag?(:api_only) %} + module Invidious::Routes::Embed def self.redirect(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index c88e96cf..d9280529 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -1,3 +1,5 @@ +{% skip_file if flag?(:api_only) %} + module Invidious::Routes::Feeds def self.view_all_playlists_redirect(env) env.redirect "/feed/playlists" diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index f052d3f4..e7aef289 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -1,3 +1,5 @@ +{% skip_file if flag?(:api_only) %} + module Invidious::Routes::Login def self.login_page(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index 82c40a95..0e6356d0 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -1,3 +1,5 @@ +{% skip_file if flag?(:api_only) %} + module Invidious::Routes::Misc def self.home(env) preferences = env.get("preferences").as(Preferences) diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 05a198d8..5ab15093 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -1,3 +1,5 @@ +{% skip_file if flag?(:api_only) %} + module Invidious::Routes::Playlists def self.new(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 0f26ec15..9410ac30 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -1,3 +1,5 @@ +{% skip_file if flag?(:api_only) %} + module Invidious::Routes::PreferencesRoute def self.show(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 610d5031..3f1e219f 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -1,3 +1,5 @@ +{% skip_file if flag?(:api_only) %} + module Invidious::Routes::Search def self.opensearch(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index f07b1358..2db133ee 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -1,3 +1,5 @@ +{% skip_file if flag?(:api_only) %} + module Invidious::Routes::Watch def self.handle(env) locale = LOCALES[env.get("preferences").as(Preferences).locale]? -- cgit v1.2.3 From 4982bff74df677c9e615b52075bd05d0006acc69 Mon Sep 17 00:00:00 2001 From: Jorge Maldonado Ventura Date: Mon, 4 Oct 2021 19:47:57 +0200 Subject: Fix typo (#2456) --- src/invidious/views/preferences.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index be021c59..2280588b 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -185,7 +185,7 @@ <%= translate(locale, "Miscellaneous preferences") %>
- + checked<% end %>>
-- cgit v1.2.3 From d806310665c692e25f647f38e79e42f8e1de376c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 4 Oct 2021 19:51:57 +0200 Subject: Revert "Fix typo (#2456)" (#2457) This reverts commit 4982bff74df677c9e615b52075bd05d0006acc69. --- src/invidious/views/preferences.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 2280588b..be021c59 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -185,7 +185,7 @@ <%= translate(locale, "Miscellaneous preferences") %>
- + checked<% end %>>
-- cgit v1.2.3 From adc12addfa1a78ac27f0e61bb6a594e56ac5f035 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Oct 2021 13:53:12 +0200 Subject: Add config option to display source code URL in footer (#2450) --- assets/css/default.css | 5 ++++ config/config.example.yml | 9 ++++++ locales/en-US.json | 8 ++++- src/invidious/helpers/helpers.cr | 4 +++ src/invidious/routes/preferences.cr | 2 ++ src/invidious/views/preferences.ecr | 5 ++++ src/invidious/views/template.ecr | 59 +++++++++++++++++++++---------------- 7 files changed, 66 insertions(+), 26 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index ce6c30c9..9e283fb6 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -314,6 +314,11 @@ footer a { text-decoration: underline; } +footer span { + margin: 4px 0; + display: block; +} + /* keyframes */ @keyframes spin { diff --git a/config/config.example.yml b/config/config.example.yml index d2346719..8bb19fcc 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -432,6 +432,15 @@ feed_threads: 1 ## #cache_annotations: false +## +## Source code URL. If your instance is running a modfied source +## code, you MUST publish it somewhere and set this option. +## +## Accepted values: a string +## Default: +## +#modified_source_code_url: "" + ######################################### diff --git a/locales/en-US.json b/locales/en-US.json index 083b539e..1fa1983d 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -425,5 +425,11 @@ "Current version: ": "Current version: ", "next_steps_error_message": "After which you should try to: ", "next_steps_error_message_refresh": "Refresh", - "next_steps_error_message_go_to_youtube": "Go to YouTube" + "next_steps_error_message_go_to_youtube": "Go to YouTube", + "footer_donate": "Donate: ", + "footer_documentation": "Documentation", + "footer_source_code": "Source code", + "footer_original_source_code": "Original source code", + "footer_modfied_source_code": "Modified Source code", + "adminprefs_modified_source_code_url_label": "URL to modified source code repository" } diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 99adcd30..968062d6 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -97,6 +97,10 @@ class Config property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' + # URL to the modified source code to be easily AGPL compliant + # Will display in the footer, next to the main source code link + property modified_source_code_url : String? = nil + @[YAML::Field(converter: Preferences::FamilyConverter)] property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 9410ac30..ae5407dc 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -200,6 +200,8 @@ module Invidious::Routes::PreferencesRoute statistics_enabled ||= "off" CONFIG.statistics_enabled = statistics_enabled == "on" + CONFIG.modified_source_code_url = env.params.body["modified_source_code_url"]?.try &.as(String) + File.write("config/config.yml", CONFIG.to_yaml) end else diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index be021c59..401c15ea 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -286,6 +286,11 @@ checked<% end %>>
+ +
+ + checked<% end %>> +
<% end %> <% if env.get? "user" %> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 7be95959..b7020598 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -117,38 +117,47 @@ +
-- cgit v1.2.3 From 21e29411af3529200cf8bbb880b5a919c1d32f8f Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Thu, 7 Oct 2021 21:39:21 +0000 Subject: Fix extractor bugs (#2454) * Add debug/trace logging to extract_items * Handle invalid timestamps for livestreams extraction * Make use of author_fallback in playlist extractor * Don't use extract_text for video length extraction The extract_text function attempts to extract from both the simpleText and the runs route. This is typically what we'd want for text extraction as it could appear in both locations. However, while this still holds true, the thumbnailOverlayTimeStatusRenderer writes a numerical length (when present on the video) to the simpleText route and uses runs for a text overlay like "LIVE" or "PREMIERE". Therefore, when a video has a text overlay instead of a numerical one, Invidious still passes it onto decode_length_seconds, which obviously raises since it cannot be converted into integers. In the future, if more routes requires one text route over the other, we should go ahead and add an argument to extract_text itself. Though for now, this is sufficient. * Handle unsupported "special" categories --- src/invidious/helpers/extractors.cr | 77 ++++++++++++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 850c93ec..c8a6cd4a 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -74,7 +74,15 @@ private module Parsers if length_container = item_contents["lengthText"]? length_seconds = decode_length_seconds(length_container["simpleText"].as_s) elsif length_container = item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?) - length_seconds = extract_text(length_container["thumbnailOverlayTimeStatusRenderer"]["text"]).try { |t| decode_length_seconds(t) } || 0 + # This needs to only go down the `simpleText` path (if possible). If more situations came up that requires + # a specific pathway then we should add an argument to extract_text that'll make this possible + length_seconds = length_container.dig?("thumbnailOverlayTimeStatusRenderer", "text", "simpleText") + + if length_seconds + length_seconds = decode_length_seconds(length_seconds.as_s) + else + length_seconds = 0 + end else length_seconds = 0 end @@ -113,6 +121,10 @@ private module Parsers premiere_timestamp: premiere_timestamp, }) end + + def self.parser_name + return {{@type.name}} + end end # Parses a InnerTube channelRenderer into a SearchChannel. Returns nil when the given object isn't a channelRenderer @@ -159,6 +171,10 @@ private module Parsers auto_generated: auto_generated, }) end + + def self.parser_name + return {{@type.name}} + end end # Parses a InnerTube gridPlaylistRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridPlaylistRenderer @@ -194,6 +210,10 @@ private module Parsers thumbnail: playlist_thumbnail, }) end + + def self.parser_name + return {{@type.name}} + end end # Parses a InnerTube playlistRenderer into a SearchPlaylist. Returns nil when the given object isn't a playlistRenderer @@ -207,20 +227,20 @@ private module Parsers module PlaylistRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = item["playlistRenderer"]? - return self.parse(item_contents) + return self.parse(item_contents, author_fallback) end end - private def self.parse(item_contents) + private def self.parse(item_contents, author_fallback) title = item_contents["title"]["simpleText"]?.try &.as_s || "" plid = item_contents["playlistId"]?.try &.as_s || "" video_count = HelperExtractors.get_video_count(item_contents) playlist_thumbnail = HelperExtractors.get_thumbnails_plural(item_contents) - author_info = item_contents.dig("shortBylineText", "runs", 0) - author = author_info["text"].as_s - author_id = HelperExtractors.get_browse_id(author_info) + author_info = item_contents.dig?("shortBylineText", "runs", 0) + author = author_info.try &.["text"].as_s || author_fallback.name + author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id videos = item_contents["videos"]?.try &.as_a.map do |v| v = v["childVideoRenderer"] @@ -246,6 +266,10 @@ private module Parsers thumbnail: playlist_thumbnail, }) end + + def self.parser_name + return {{@type.name}} + end end # Parses a InnerTube shelfRenderer into a Category. Returns nil when the given object isn't a shelfRenderer @@ -283,11 +307,17 @@ private module Parsers # Content parsing contents = [] of SearchItem - # Content could be in three locations. - if content_container = item_contents["content"]["horizontalListRenderer"]? - elsif content_container = item_contents["content"]["expandedShelfContentsRenderer"]? - elsif content_container = item_contents["content"]["verticalListRenderer"]? + # InnerTube recognizes some "special" categories, which are organized differently. + if special_category_container = item_contents["content"]? + if content_container = special_category_container["horizontalListRenderer"]? + elsif content_container = special_category_container["expandedShelfContentsRenderer"]? + elsif content_container = special_category_container["verticalListRenderer"]? + else + # Anything else, such as `horizontalMovieListRenderer` is currently unsupported. + return + end else + # "Normal" category. content_container = item_contents["contents"] end @@ -307,6 +337,10 @@ private module Parsers badges: badges, }) end + + def self.parser_name + return {{@type.name}} + end end end @@ -372,6 +406,10 @@ private module Extractors return raw_items end + + def self.extractor_name + return {{@type.name}} + end end # Extracts items from the InnerTube response for search results @@ -409,6 +447,10 @@ private module Extractors return raw_items.flatten end + + def self.extractor_name + return {{@type.name}} + end end # Extracts continuation items from a InnerTube response @@ -440,6 +482,10 @@ private module Extractors return raw_items end + + def self.extractor_name + return {{@type.name}} + end end end @@ -529,8 +575,14 @@ def extract_item(item : JSON::Any, author_fallback : String? = "", # Each parser automatically validates the data given to see if the data is # applicable to itself. If not nil is returned and the next parser is attemped. ITEM_PARSERS.each do |parser| + LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") + if result = parser.process(item, author_fallback) + LOGGER.debug("extract_item: Successfully parsed via #{parser.parser_name}") + return result + else + LOGGER.trace("extract_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...") end end end @@ -550,7 +602,10 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri # This is identical to the parser cycling of extract_item(). ITEM_CONTAINER_EXTRACTOR.each do |extractor| + LOGGER.trace("extract_items: Attempting to extract item container using \"#{extractor.extractor_name}\" (cycling...)") + if container = extractor.process(unpackaged_data) + LOGGER.debug("extract_items: Successfully unpacked container with \"#{extractor.extractor_name}\"") # Extract items in container container.each do |item| if parsed_result = extract_item(item, author_fallback, author_id_fallback) @@ -559,6 +614,8 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri end break + else + LOGGER.trace("extract_items: Extractor \"#{extractor.extractor_name}\" does not apply. Cycling to the next one...") end end -- cgit v1.2.3 From c6f088d6caa5a7387883d94144d761489e17cadf Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 8 Oct 2021 18:39:02 +0200 Subject: Reduce refresh delay, increase backoff start duration --- src/invidious/jobs/refresh_channels_job.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 0adb4b5a..2321e964 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -9,7 +9,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob lim_fibers = max_fibers active_fibers = 0 active_channel = Channel(Bool).new - backoff = 1.seconds + backoff = 2.minutes loop do LOGGER.debug("RefreshChannelsJob: Refreshing all channels") @@ -59,8 +59,8 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob end # TODO: make this configurable - LOGGER.debug("RefreshChannelsJob: Done, sleeping for one hour") - sleep 1.hour + LOGGER.debug("RefreshChannelsJob: Done, sleeping for thirty minutes") + sleep 30.minutes Fiber.yield end end -- cgit v1.2.3 From 0947c266122a49e8452e9a3efb978a1e38048868 Mon Sep 17 00:00:00 2001 From: mastihios <91783447+mastihios@users.noreply.github.com> Date: Mon, 11 Oct 2021 12:18:20 +0000 Subject: Fix URL-encoding in href strings (#2460) * hrefs: replace HTML.escape w/ URI.encode_www_form * Fix search_query_encoded --- src/invidious/views/add_playlist_items.ecr | 4 ++-- src/invidious/views/channel.ecr | 4 ++-- src/invidious/views/components/item.ecr | 4 ++-- src/invidious/views/playlists.ecr | 2 +- src/invidious/views/search.ecr | 14 +++++++------- 5 files changed, 14 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index 09eacbc8..c62861b0 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -41,7 +41,7 @@
<% if page > 1 %> - + <%= translate(locale, "Previous page") %> <% end %> @@ -49,7 +49,7 @@
<% if count >= 20 %> - + <%= translate(locale, "Next page") %> <% end %> diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 09cfb76e..7f797e37 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -96,7 +96,7 @@
diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index d9a17a9b..1245256f 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -96,7 +96,7 @@
<% if continuation %> - &sort_by=<%= HTML.escape(sort_by) %><% end %>"> + &sort_by=<%= URI.encode_www_form(sort_by) %><% end %>"> <%= translate(locale, "Next page") %> <% end %> diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index fd176e41..db374548 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -2,7 +2,7 @@ <%= search_query.not_nil!.size > 30 ? HTML.escape(query.not_nil![0,30].rstrip(".") + "...") : HTML.escape(query.not_nil!) %> - Invidious <% end %> -<% search_query_encoded = env.get?("search").try { |x| URI.encode(x.as(String), space_to_plus: true) } %> +<% search_query_encoded = env.get?("search").try { |x| URI.encode_www_form(x.as(String), space_to_plus: true) } %> <% if count == 0 %> @@ -23,7 +23,7 @@ <% if operator_hash.fetch("date", "all") == date %> <%= translate(locale, date) %> <% else %> - &page=<%= page %>"> + &page=<%= page %>"> <%= translate(locale, date) %> <% end %> @@ -38,7 +38,7 @@ <% if operator_hash.fetch("content_type", "all") == content_type %> <%= translate(locale, content_type) %> <% else %> - &page=<%= page %>"> + &page=<%= page %>"> <%= translate(locale, content_type) %> <% end %> @@ -53,7 +53,7 @@ <% if operator_hash.fetch("duration", "all") == duration %> <%= translate(locale, duration) %> <% else %> - &page=<%= page %>"> + &page=<%= page %>"> <%= translate(locale, duration) %> <% end %> @@ -68,11 +68,11 @@ <% if operator_hash.fetch("features", "all").includes?(feature) %> <%= translate(locale, feature) %> <% elsif operator_hash.has_key?("features") %> - &page=<%= page %>"> + &page=<%= page %>"> <%= translate(locale, feature) %> <% else %> - &page=<%= page %>"> + &page=<%= page %>"> <%= translate(locale, feature) %> <% end %> @@ -87,7 +87,7 @@ <% if operator_hash.fetch("sort", "relevance") == sort %> <%= translate(locale, sort) %> <% else %> - &page=<%= page %>"> + &page=<%= page %>"> <%= translate(locale, sort) %> <% end %> -- cgit v1.2.3 From b10f37bea972fde6586639511bf8eaab490581fd Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Mon, 11 Oct 2021 12:42:22 +0000 Subject: Use kemal in production mode (#2455) --- src/invidious.cr | 7 +++++++ 1 file changed, 7 insertions(+) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 9e67e216..73abe6b0 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -1549,4 +1549,11 @@ Kemal.config.logger = LOGGER Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port Kemal.config.app_name = "Invidious" + +# Use in kemal's production mode. +# Users can also set the KEMAL_ENV environmental variable for this to be set automatically. +{% if flag?(:release) || flag?(:production) %} + Kemal.config.env = "production" if !ENV.has_key?("KEMAL_ENV") +{% end %} + Kemal.run -- cgit v1.2.3 From 984a4acc7bc629d8da6e43507a4880c43a4b4ec4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Oct 2021 21:43:42 +0200 Subject: Move user preferences structure to a separate file --- src/invidious/user/preferences.cr | 257 +++++++++++++++++++++++++++++++++++++ src/invidious/users.cr | 258 -------------------------------------- 2 files changed, 257 insertions(+), 258 deletions(-) create mode 100644 src/invidious/user/preferences.cr (limited to 'src') diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr new file mode 100644 index 00000000..453a635e --- /dev/null +++ b/src/invidious/user/preferences.cr @@ -0,0 +1,257 @@ +struct Preferences + include JSON::Serializable + include YAML::Serializable + + property annotations : Bool = CONFIG.default_user_preferences.annotations + property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed + property autoplay : Bool = CONFIG.default_user_preferences.autoplay + property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect + + @[JSON::Field(converter: Preferences::StringToArray)] + @[YAML::Field(converter: Preferences::StringToArray)] + property captions : Array(String) = CONFIG.default_user_preferences.captions + + @[JSON::Field(converter: Preferences::StringToArray)] + @[YAML::Field(converter: Preferences::StringToArray)] + property comments : Array(String) = CONFIG.default_user_preferences.comments + property continue : Bool = CONFIG.default_user_preferences.continue + property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay + + @[JSON::Field(converter: Preferences::BoolToString)] + @[YAML::Field(converter: Preferences::BoolToString)] + property dark_mode : String = CONFIG.default_user_preferences.dark_mode + property latest_only : Bool = CONFIG.default_user_preferences.latest_only + property listen : Bool = CONFIG.default_user_preferences.listen + property local : Bool = CONFIG.default_user_preferences.local + property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode + property show_nick : Bool = CONFIG.default_user_preferences.show_nick + + @[JSON::Field(converter: Preferences::ProcessString)] + property locale : String = CONFIG.default_user_preferences.locale + + @[JSON::Field(converter: Preferences::ClampInt)] + property max_results : Int32 = CONFIG.default_user_preferences.max_results + property notifications_only : Bool = CONFIG.default_user_preferences.notifications_only + + @[JSON::Field(converter: Preferences::ProcessString)] + property player_style : String = CONFIG.default_user_preferences.player_style + + @[JSON::Field(converter: Preferences::ProcessString)] + property quality : String = CONFIG.default_user_preferences.quality + @[JSON::Field(converter: Preferences::ProcessString)] + property quality_dash : String = CONFIG.default_user_preferences.quality_dash + property default_home : String? = CONFIG.default_user_preferences.default_home + property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu + property related_videos : Bool = CONFIG.default_user_preferences.related_videos + + @[JSON::Field(converter: Preferences::ProcessString)] + property sort : String = CONFIG.default_user_preferences.sort + property speed : Float32 = CONFIG.default_user_preferences.speed + property thin_mode : Bool = CONFIG.default_user_preferences.thin_mode + property unseen_only : Bool = CONFIG.default_user_preferences.unseen_only + property video_loop : Bool = CONFIG.default_user_preferences.video_loop + property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc + property volume : Int32 = CONFIG.default_user_preferences.volume + + module BoolToString + def self.to_json(value : String, json : JSON::Builder) + json.string value + end + + def self.from_json(value : JSON::PullParser) : String + begin + result = value.read_string + + if result.empty? + CONFIG.default_user_preferences.dark_mode + else + result + end + rescue ex + if value.read_bool + "dark" + else + "light" + end + end + end + + def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) + yaml.scalar value + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String + unless node.is_a?(YAML::Nodes::Scalar) + node.raise "Expected scalar, not #{node.class}" + end + + case node.value + when "true" + "dark" + when "false" + "light" + when "" + CONFIG.default_user_preferences.dark_mode + else + node.value + end + end + end + + module ClampInt + def self.to_json(value : Int32, json : JSON::Builder) + json.number value + end + + def self.from_json(value : JSON::PullParser) : Int32 + value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32 + end + + def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder) + yaml.scalar value + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32 + node.value.clamp(0, MAX_ITEMS_PER_PAGE) + end + end + + module FamilyConverter + def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder) + case value + when Socket::Family::UNSPEC + yaml.scalar nil + when Socket::Family::INET + yaml.scalar "ipv4" + when Socket::Family::INET6 + yaml.scalar "ipv6" + when Socket::Family::UNIX + raise "Invalid socket family #{value}" + end + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family + if node.is_a?(YAML::Nodes::Scalar) + case node.value.downcase + when "ipv4" + Socket::Family::INET + when "ipv6" + Socket::Family::INET6 + else + Socket::Family::UNSPEC + end + else + node.raise "Expected scalar, not #{node.class}" + end + end + end + + module URIConverter + def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder) + yaml.scalar value.normalize! + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI + if node.is_a?(YAML::Nodes::Scalar) + URI.parse node.value + else + node.raise "Expected scalar, not #{node.class}" + end + end + end + + module ProcessString + def self.to_json(value : String, json : JSON::Builder) + json.string value + end + + def self.from_json(value : JSON::PullParser) : String + HTML.escape(value.read_string[0, 100]) + end + + def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) + yaml.scalar value + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String + HTML.escape(node.value[0, 100]) + end + end + + module StringToArray + def self.to_json(value : Array(String), json : JSON::Builder) + json.array do + value.each do |element| + json.string element + end + end + end + + def self.from_json(value : JSON::PullParser) : Array(String) + begin + result = [] of String + value.read_array do + result << HTML.escape(value.read_string[0, 100]) + end + rescue ex + result = [HTML.escape(value.read_string[0, 100]), ""] + end + + result + end + + def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) + yaml.sequence do + value.each do |element| + yaml.scalar element + end + end + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String) + begin + unless node.is_a?(YAML::Nodes::Sequence) + node.raise "Expected sequence, not #{node.class}" + end + + result = [] of String + node.nodes.each do |item| + unless item.is_a?(YAML::Nodes::Scalar) + node.raise "Expected scalar, not #{item.class}" + end + + result << HTML.escape(item.value[0, 100]) + end + rescue ex + if node.is_a?(YAML::Nodes::Scalar) + result = [HTML.escape(node.value[0, 100]), ""] + else + result = ["", ""] + end + end + + result + end + end + + module StringToCookies + def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder) + (value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml) + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies + unless node.is_a?(YAML::Nodes::Scalar) + node.raise "Expected scalar, not #{node.class}" + end + + cookies = HTTP::Cookies.new + node.value.split(";").each do |cookie| + next if cookie.strip.empty? + name, value = cookie.split("=", 2) + cookies << HTTP::Cookie.new(name.strip, value.strip) + end + + cookies + end + end +end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index aff76b53..8ea7bd4a 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -29,264 +29,6 @@ struct User end end -struct Preferences - include JSON::Serializable - include YAML::Serializable - - property annotations : Bool = CONFIG.default_user_preferences.annotations - property annotations_subscribed : Bool = CONFIG.default_user_preferences.annotations_subscribed - property autoplay : Bool = CONFIG.default_user_preferences.autoplay - property automatic_instance_redirect : Bool = CONFIG.default_user_preferences.automatic_instance_redirect - - @[JSON::Field(converter: Preferences::StringToArray)] - @[YAML::Field(converter: Preferences::StringToArray)] - property captions : Array(String) = CONFIG.default_user_preferences.captions - - @[JSON::Field(converter: Preferences::StringToArray)] - @[YAML::Field(converter: Preferences::StringToArray)] - property comments : Array(String) = CONFIG.default_user_preferences.comments - property continue : Bool = CONFIG.default_user_preferences.continue - property continue_autoplay : Bool = CONFIG.default_user_preferences.continue_autoplay - - @[JSON::Field(converter: Preferences::BoolToString)] - @[YAML::Field(converter: Preferences::BoolToString)] - property dark_mode : String = CONFIG.default_user_preferences.dark_mode - property latest_only : Bool = CONFIG.default_user_preferences.latest_only - property listen : Bool = CONFIG.default_user_preferences.listen - property local : Bool = CONFIG.default_user_preferences.local - property vr_mode : Bool = CONFIG.default_user_preferences.vr_mode - property show_nick : Bool = CONFIG.default_user_preferences.show_nick - - @[JSON::Field(converter: Preferences::ProcessString)] - property locale : String = CONFIG.default_user_preferences.locale - - @[JSON::Field(converter: Preferences::ClampInt)] - property max_results : Int32 = CONFIG.default_user_preferences.max_results - property notifications_only : Bool = CONFIG.default_user_preferences.notifications_only - - @[JSON::Field(converter: Preferences::ProcessString)] - property player_style : String = CONFIG.default_user_preferences.player_style - - @[JSON::Field(converter: Preferences::ProcessString)] - property quality : String = CONFIG.default_user_preferences.quality - @[JSON::Field(converter: Preferences::ProcessString)] - property quality_dash : String = CONFIG.default_user_preferences.quality_dash - property default_home : String? = CONFIG.default_user_preferences.default_home - property feed_menu : Array(String) = CONFIG.default_user_preferences.feed_menu - property related_videos : Bool = CONFIG.default_user_preferences.related_videos - - @[JSON::Field(converter: Preferences::ProcessString)] - property sort : String = CONFIG.default_user_preferences.sort - property speed : Float32 = CONFIG.default_user_preferences.speed - property thin_mode : Bool = CONFIG.default_user_preferences.thin_mode - property unseen_only : Bool = CONFIG.default_user_preferences.unseen_only - property video_loop : Bool = CONFIG.default_user_preferences.video_loop - property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc - property volume : Int32 = CONFIG.default_user_preferences.volume - - module BoolToString - def self.to_json(value : String, json : JSON::Builder) - json.string value - end - - def self.from_json(value : JSON::PullParser) : String - begin - result = value.read_string - - if result.empty? - CONFIG.default_user_preferences.dark_mode - else - result - end - rescue ex - if value.read_bool - "dark" - else - "light" - end - end - end - - def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) - yaml.scalar value - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String - unless node.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{node.class}" - end - - case node.value - when "true" - "dark" - when "false" - "light" - when "" - CONFIG.default_user_preferences.dark_mode - else - node.value - end - end - end - - module ClampInt - def self.to_json(value : Int32, json : JSON::Builder) - json.number value - end - - def self.from_json(value : JSON::PullParser) : Int32 - value.read_int.clamp(0, MAX_ITEMS_PER_PAGE).to_i32 - end - - def self.to_yaml(value : Int32, yaml : YAML::Nodes::Builder) - yaml.scalar value - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Int32 - node.value.clamp(0, MAX_ITEMS_PER_PAGE) - end - end - - module FamilyConverter - def self.to_yaml(value : Socket::Family, yaml : YAML::Nodes::Builder) - case value - when Socket::Family::UNSPEC - yaml.scalar nil - when Socket::Family::INET - yaml.scalar "ipv4" - when Socket::Family::INET6 - yaml.scalar "ipv6" - when Socket::Family::UNIX - raise "Invalid socket family #{value}" - end - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Socket::Family - if node.is_a?(YAML::Nodes::Scalar) - case node.value.downcase - when "ipv4" - Socket::Family::INET - when "ipv6" - Socket::Family::INET6 - else - Socket::Family::UNSPEC - end - else - node.raise "Expected scalar, not #{node.class}" - end - end - end - - module URIConverter - def self.to_yaml(value : URI, yaml : YAML::Nodes::Builder) - yaml.scalar value.normalize! - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : URI - if node.is_a?(YAML::Nodes::Scalar) - URI.parse node.value - else - node.raise "Expected scalar, not #{node.class}" - end - end - end - - module ProcessString - def self.to_json(value : String, json : JSON::Builder) - json.string value - end - - def self.from_json(value : JSON::PullParser) : String - HTML.escape(value.read_string[0, 100]) - end - - def self.to_yaml(value : String, yaml : YAML::Nodes::Builder) - yaml.scalar value - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : String - HTML.escape(node.value[0, 100]) - end - end - - module StringToArray - def self.to_json(value : Array(String), json : JSON::Builder) - json.array do - value.each do |element| - json.string element - end - end - end - - def self.from_json(value : JSON::PullParser) : Array(String) - begin - result = [] of String - value.read_array do - result << HTML.escape(value.read_string[0, 100]) - end - rescue ex - result = [HTML.escape(value.read_string[0, 100]), ""] - end - - result - end - - def self.to_yaml(value : Array(String), yaml : YAML::Nodes::Builder) - yaml.sequence do - value.each do |element| - yaml.scalar element - end - end - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Array(String) - begin - unless node.is_a?(YAML::Nodes::Sequence) - node.raise "Expected sequence, not #{node.class}" - end - - result = [] of String - node.nodes.each do |item| - unless item.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{item.class}" - end - - result << HTML.escape(item.value[0, 100]) - end - rescue ex - if node.is_a?(YAML::Nodes::Scalar) - result = [HTML.escape(node.value[0, 100]), ""] - else - result = ["", ""] - end - end - - result - end - end - - module StringToCookies - def self.to_yaml(value : HTTP::Cookies, yaml : YAML::Nodes::Builder) - (value.map { |c| "#{c.name}=#{c.value}" }).join("; ").to_yaml(yaml) - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : HTTP::Cookies - unless node.is_a?(YAML::Nodes::Scalar) - node.raise "Expected scalar, not #{node.class}" - end - - cookies = HTTP::Cookies.new - node.value.split(";").each do |cookie| - next if cookie.strip.empty? - name, value = cookie.split("=", 2) - cookies << HTTP::Cookie.new(name.strip, value.strip) - end - - cookies - end - end -end - def get_user(sid, headers, db, refresh = true) if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User) -- cgit v1.2.3 From 080d9a8dc72a1d932dcda07bb93d21fdcc98496a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Oct 2021 21:55:21 +0200 Subject: move theme converter function to a separate file --- src/invidious/helpers/utils.cr | 13 ------------- src/invidious/user/converters.cr | 12 ++++++++++++ 2 files changed, 12 insertions(+), 13 deletions(-) create mode 100644 src/invidious/user/converters.cr (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 68ba76f9..af5f553b 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -397,19 +397,6 @@ def parse_range(range) return 0_i64, nil end -def convert_theme(theme) - case theme - when "true" - "dark" - when "false" - "light" - when "", nil - nil - else - theme - end -end - def fetch_random_instance begin instance_api_client = make_client(URI.parse("https://api.invidious.io")) diff --git a/src/invidious/user/converters.cr b/src/invidious/user/converters.cr new file mode 100644 index 00000000..dcbf8c53 --- /dev/null +++ b/src/invidious/user/converters.cr @@ -0,0 +1,12 @@ +def convert_theme(theme) + case theme + when "true" + "dark" + when "false" + "light" + when "", nil + nil + else + theme + end +end -- cgit v1.2.3 From bda3a264233420de601e5c10c5dbf2686457c291 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Oct 2021 21:56:49 +0200 Subject: Move server structure to a separate file --- src/invidious/config.cr | 190 ++++++++++++++++++++++++++++++++++++++ src/invidious/helpers/helpers.cr | 191 --------------------------------------- 2 files changed, 190 insertions(+), 191 deletions(-) create mode 100644 src/invidious/config.cr (limited to 'src') diff --git a/src/invidious/config.cr b/src/invidious/config.cr new file mode 100644 index 00000000..e2bc5722 --- /dev/null +++ b/src/invidious/config.cr @@ -0,0 +1,190 @@ +struct DBConfig + include YAML::Serializable + + property user : String + property password : String + property host : String + property port : Int32 + property dbname : String +end + +struct ConfigPreferences + include YAML::Serializable + + property annotations : Bool = false + property annotations_subscribed : Bool = false + property autoplay : Bool = false + property captions : Array(String) = ["", "", ""] + property comments : Array(String) = ["youtube", ""] + property continue : Bool = false + property continue_autoplay : Bool = true + property dark_mode : String = "" + property latest_only : Bool = false + property listen : Bool = false + property local : Bool = false + property locale : String = "en-US" + property max_results : Int32 = 40 + property notifications_only : Bool = false + property player_style : String = "invidious" + property quality : String = "hd720" + property quality_dash : String = "auto" + property default_home : String? = "Popular" + property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] + property automatic_instance_redirect : Bool = false + property related_videos : Bool = true + property sort : String = "published" + property speed : Float32 = 1.0_f32 + property thin_mode : Bool = false + property unseen_only : Bool = false + property video_loop : Bool = false + property extend_desc : Bool = false + property volume : Int32 = 100 + property vr_mode : Bool = true + property show_nick : Bool = true + + def to_tuple + {% begin %} + { + {{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}} + } + {% end %} + end +end + +class Config + include YAML::Serializable + + property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) + property feed_threads : Int32 = 1 # Number of threads to use for updating feeds + property output : String = "STDOUT" # Log file path or STDOUT + property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr + property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc) + + @[YAML::Field(converter: Preferences::URIConverter)] + property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax + property decrypt_polling : Bool = true # Use polling to keep decryption function up to date + property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel + property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https:// + property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions + property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required + property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) + property popular_enabled : Bool = true + property captcha_enabled : Bool = true + property login_enabled : Bool = true + property registration_enabled : Bool = true + property statistics_enabled : Bool = false + property admins : Array(String) = [] of String + property external_port : Int32? = nil + property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("") + property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs + property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc. + property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards + property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc. + property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely + property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' + + # URL to the modified source code to be easily AGPL compliant + # Will display in the footer, next to the main source code link + property modified_source_code_url : String? = nil + + @[YAML::Field(converter: Preferences::FamilyConverter)] + property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) + property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) + property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument) + property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) + property use_quic : Bool = true # Use quic transport for youtube api + + @[YAML::Field(converter: Preferences::StringToCookies)] + property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format + property captcha_key : String? = nil # Key for Anti-Captcha + property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha + + def disabled?(option) + case disabled = CONFIG.disable_proxy + when Bool + return disabled + when Array + if disabled.includes? option + return true + else + return false + end + else + return false + end + end + + def self.load + # Load config from file or YAML string env var + env_config_file = "INVIDIOUS_CONFIG_FILE" + env_config_yaml = "INVIDIOUS_CONFIG" + + config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml" + config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file) + + config = Config.from_yaml(config_yaml) + + # Update config from env vars (upcased and prefixed with "INVIDIOUS_") + {% for ivar in Config.instance_vars %} + {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} + + if ENV.has_key?({{env_id}}) + # puts %(Config.{{ivar.id}} : Loading from env var {{env_id}}) + env_value = ENV.fetch({{env_id}}) + success = false + + # Use YAML converter if specified + {% ann = ivar.annotation(::YAML::Field) %} + {% if ann && ann[:converter] %} + puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter) + config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0]) + puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}}) + success = true + + # Use regular YAML parser otherwise + {% else %} + {% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %} + # Sort types to avoid parsing nulls and numbers as strings + {% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %} + {{ivar_types}}.each do |ivar_type| + if !success + begin + # puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type}) + config.{{ivar.id}} = ivar_type.from_yaml(env_value) + puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type})) + success = true + rescue + # nop + end + end + end + {% end %} + + # Exit on fail + if !success + puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}}) + exit(1) + end + end + {% end %} + + # Build database_url from db.* if it's not set directly + if config.database_url.to_s.empty? + if db = config.db + config.database_url = URI.new( + scheme: "postgres", + user: db.user, + password: db.password, + host: db.host, + port: db.port, + path: db.dbname, + ) + else + puts "Config : Either database_url or db.* is required" + exit(1) + end + end + + return config + end +end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 968062d6..91377b0d 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -22,197 +22,6 @@ struct Annotation property annotations : String end -struct ConfigPreferences - include YAML::Serializable - - property annotations : Bool = false - property annotations_subscribed : Bool = false - property autoplay : Bool = false - property captions : Array(String) = ["", "", ""] - property comments : Array(String) = ["youtube", ""] - property continue : Bool = false - property continue_autoplay : Bool = true - property dark_mode : String = "" - property latest_only : Bool = false - property listen : Bool = false - property local : Bool = false - property locale : String = "en-US" - property max_results : Int32 = 40 - property notifications_only : Bool = false - property player_style : String = "invidious" - property quality : String = "hd720" - property quality_dash : String = "auto" - property default_home : String? = "Popular" - property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] - property automatic_instance_redirect : Bool = false - property related_videos : Bool = true - property sort : String = "published" - property speed : Float32 = 1.0_f32 - property thin_mode : Bool = false - property unseen_only : Bool = false - property video_loop : Bool = false - property extend_desc : Bool = false - property volume : Int32 = 100 - property vr_mode : Bool = true - property show_nick : Bool = true - - def to_tuple - {% begin %} - { - {{*@type.instance_vars.map { |var| "#{var.name}: #{var.name}".id }}} - } - {% end %} - end -end - -class Config - include YAML::Serializable - - property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) - property feed_threads : Int32 = 1 # Number of threads to use for updating feeds - property output : String = "STDOUT" # Log file path or STDOUT - property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr - property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc) - - @[YAML::Field(converter: Preferences::URIConverter)] - property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax - property decrypt_polling : Bool = true # Use polling to keep decryption function up to date - property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel - property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https:// - property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions - property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required - property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) - property popular_enabled : Bool = true - property captcha_enabled : Bool = true - property login_enabled : Bool = true - property registration_enabled : Bool = true - property statistics_enabled : Bool = false - property admins : Array(String) = [] of String - property external_port : Int32? = nil - property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("") - property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs - property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc. - property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards - property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc. - property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely - property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' - - # URL to the modified source code to be easily AGPL compliant - # Will display in the footer, next to the main source code link - property modified_source_code_url : String? = nil - - @[YAML::Field(converter: Preferences::FamilyConverter)] - property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) - property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) - property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument) - property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) - property use_quic : Bool = true # Use quic transport for youtube api - - @[YAML::Field(converter: Preferences::StringToCookies)] - property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format - property captcha_key : String? = nil # Key for Anti-Captcha - property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha - - def disabled?(option) - case disabled = CONFIG.disable_proxy - when Bool - return disabled - when Array - if disabled.includes? option - return true - else - return false - end - else - return false - end - end - - def self.load - # Load config from file or YAML string env var - env_config_file = "INVIDIOUS_CONFIG_FILE" - env_config_yaml = "INVIDIOUS_CONFIG" - - config_file = ENV.has_key?(env_config_file) ? ENV.fetch(env_config_file) : "config/config.yml" - config_yaml = ENV.has_key?(env_config_yaml) ? ENV.fetch(env_config_yaml) : File.read(config_file) - - config = Config.from_yaml(config_yaml) - - # Update config from env vars (upcased and prefixed with "INVIDIOUS_") - {% for ivar in Config.instance_vars %} - {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} - - if ENV.has_key?({{env_id}}) - # puts %(Config.{{ivar.id}} : Loading from env var {{env_id}}) - env_value = ENV.fetch({{env_id}}) - success = false - - # Use YAML converter if specified - {% ann = ivar.annotation(::YAML::Field) %} - {% if ann && ann[:converter] %} - puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter) - config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0]) - puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}}) - success = true - - # Use regular YAML parser otherwise - {% else %} - {% ivar_types = ivar.type.union? ? ivar.type.union_types : [ivar.type] %} - # Sort types to avoid parsing nulls and numbers as strings - {% ivar_types = ivar_types.sort_by { |ivar_type| ivar_type == Nil ? 0 : ivar_type == Int32 ? 1 : 2 } %} - {{ivar_types}}.each do |ivar_type| - if !success - begin - # puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type}) - config.{{ivar.id}} = ivar_type.from_yaml(env_value) - puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type})) - success = true - rescue - # nop - end - end - end - {% end %} - - # Exit on fail - if !success - puts %(Config.{{ivar.id}} failed to parse #{env_value} as {{ivar.type}}) - exit(1) - end - end - {% end %} - - # Build database_url from db.* if it's not set directly - if config.database_url.to_s.empty? - if db = config.db - config.database_url = URI.new( - scheme: "postgres", - user: db.user, - password: db.password, - host: db.host, - port: db.port, - path: db.dbname, - ) - else - puts "Config : Either database_url or db.* is required" - exit(1) - end - end - - return config - end -end - -struct DBConfig - include YAML::Serializable - - property user : String - property password : String - property host : String - property port : Int32 - property dbname : String -end - def login_req(f_req) data = { # Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard -- cgit v1.2.3 From 57ed04702558d27d78863ad4940cbdfac7e10386 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Oct 2021 22:00:50 +0200 Subject: Add mising 'require' statement to 'invidious.cr' --- src/invidious.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 73abe6b0..f8f0784a 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -29,6 +29,7 @@ require "protodec/utils" require "./invidious/helpers/*" require "./invidious/*" require "./invidious/channels/*" +require "./invidious/user/*" require "./invidious/routes/**" require "./invidious/jobs/**" -- cgit v1.2.3 From 4246c7a523451e1499a3b2b9d71d5360457b1294 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 11 Oct 2021 13:22:11 -0700 Subject: Extract image routes --- src/invidious.cr | 195 ++--------------------------------------- src/invidious/routes/images.cr | 191 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 188 deletions(-) create mode 100644 src/invidious/routes/images.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 73abe6b0..18ec0b97 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -389,6 +389,13 @@ end Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post {% end %} +Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht +Invidious::Routing.options "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :options_storyboard +Invidious::Routing.get "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :get_storyboard +Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image +Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image +Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails + # API routes (macro) define_v1_api_routes() @@ -1273,194 +1280,6 @@ post "/api/v1/auth/notifications" do |env| create_notification_stream(env, topics, connection_channel) end -get "/ggpht/*" do |env| - url = env.request.path.lchop("/ggpht") - - headers = HTTP::Headers{":authority" => "yt3.ggpht.com"} - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -options "/sb/:authority/:id/:storyboard/:index" do |env| - env.response.headers["Access-Control-Allow-Origin"] = "*" - env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" - env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" -end - -get "/sb/:authority/:id/:storyboard/:index" do |env| - authority = env.params.url["authority"] - id = env.params.url["id"] - storyboard = env.params.url["storyboard"] - index = env.params.url["index"] - - url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}" - - headers = HTTP::Headers.new - - headers[":authority"] = "#{authority}.ytimg.com" - - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Connection"] = "close" - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -get "/s_p/:id/:name" do |env| - id = env.params.url["id"] - name = env.params.url["name"] - - url = env.request.resource - - headers = HTTP::Headers{":authority" => "i9.ytimg.com"} - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -get "/yts/img/:name" do |env| - headers = HTTP::Headers.new - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(env.request.resource, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - -get "/vi/:id/:name" do |env| - id = env.params.url["id"] - name = env.params.url["name"] - - headers = HTTP::Headers{":authority" => "i.ytimg.com"} - - if name == "maxres.jpg" - build_thumbnails(id).each do |thumb| - if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200 - name = thumb[:url] + ".jpg" - break - end - end - end - url = "/vi/#{id}/#{name}" - - REQUEST_HEADERS_WHITELIST.each do |header| - if env.request.headers[header]? - headers[header] = env.request.headers[header] - end - end - - begin - YT_POOL.client &.get(url, headers) do |response| - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - env.response.headers.delete("Transfer-Encoding") - break - end - - proxy_file(response, env) - end - rescue ex - end -end - get "/Captcha" do |env| headers = HTTP::Headers{":authority" => "accounts.google.com"} response = YT_POOL.client &.get(env.request.resource, headers) diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr new file mode 100644 index 00000000..bb924cdf --- /dev/null +++ b/src/invidious/routes/images.cr @@ -0,0 +1,191 @@ +module Invidious::Routes::Images + # Avatars, banners and other large image assets. + def self.ggpht(env) + url = env.request.path.lchop("/ggpht") + + headers = HTTP::Headers{":authority" => "yt3.ggpht.com"} + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(url, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end + + def self.options_storyboard(env) + env.response.headers["Access-Control-Allow-Origin"] = "*" + env.response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS" + env.response.headers["Access-Control-Allow-Headers"] = "Content-Type, Range" + end + + def self.get_storyboard(env) + authority = env.params.url["authority"] + id = env.params.url["id"] + storyboard = env.params.url["storyboard"] + index = env.params.url["index"] + + url = "/sb/#{id}/#{storyboard}/#{index}?#{env.params.query}" + + headers = HTTP::Headers.new + + headers[":authority"] = "#{authority}.ytimg.com" + + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(url, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Connection"] = "close" + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end + + # ??? maybe also for storyboards? + def self.s_p_image(env) + id = env.params.url["id"] + name = env.params.url["name"] + + url = env.request.resource + + headers = HTTP::Headers{":authority" => "i9.ytimg.com"} + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(url, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end + + def self.yts_image(env) + headers = HTTP::Headers.new + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(env.request.resource, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end + + def self.thumbnails(env) + id = env.params.url["id"] + name = env.params.url["name"] + + headers = HTTP::Headers{":authority" => "i.ytimg.com"} + + if name == "maxres.jpg" + build_thumbnails(id).each do |thumb| + if YT_POOL.client &.head("/vi/#{id}/#{thumb[:url]}.jpg", headers).status_code == 200 + name = thumb[:url] + ".jpg" + break + end + end + end + url = "/vi/#{id}/#{name}" + + REQUEST_HEADERS_WHITELIST.each do |header| + if env.request.headers[header]? + headers[header] = env.request.headers[header] + end + end + + begin + YT_POOL.client &.get(url, headers) do |response| + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value + end + end + + env.response.headers["Access-Control-Allow-Origin"] = "*" + + if response.status_code >= 300 && response.status_code != 404 + env.response.headers.delete("Transfer-Encoding") + break + end + + proxy_file(response, env) + end + rescue ex + end + end +end -- cgit v1.2.3 From d9bfb3d3056d1c9f628c0c7d1b363ebf63977e87 Mon Sep 17 00:00:00 2001 From: diogo Date: Fri, 16 Jul 2021 23:32:48 +0200 Subject: playlist starts at the offset --- src/invidious/playlists.cr | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index f56cc2ea..6c745945 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -437,17 +437,38 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) 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 - if offset >= 100 - # Normalize offset to match youtube's behavior (100 videos chunck per request) - offset = (offset / 100).to_i64 * 100_i64 + videos = [] of PlaylistVideo + + until videos.size >= 50 || videos.size == playlist.video_count + if offset >= 100 + # Normalize offset to match youtube's behavior (100 videos chunck per request) + normalized_offset = (offset / 100).to_i64 * 100_i64 + ctoken = produce_playlist_continuation(playlist.id, normalized_offset) + initial_data = request_youtube_api_browse(ctoken) + else + initial_data = request_youtube_api_browse("VL" + playlist.id, params: "") + end - ctoken = produce_playlist_continuation(playlist.id, offset) - initial_data = YoutubeAPI.browse(ctoken) - else - initial_data = YoutubeAPI.browse("VL" + playlist.id, params: "") + videos += extract_playlist_videos(initial_data) + + if continuation + until videos[0].id == continuation + videos.shift + end + elsif + until videos[0].index == offset + videos.shift + end + end + + if offset == 0 + offset = videos[0].index + end + + offset += 50 end - return extract_playlist_videos(initial_data) + return videos end end -- cgit v1.2.3 From 7eba7fbcc79eba2c15f2fde9f6d011a7d1c1541c Mon Sep 17 00:00:00 2001 From: diogo Date: Sat, 17 Jul 2021 01:38:24 +0200 Subject: add index to playlist item --- src/invidious/playlists.cr | 2 +- src/invidious/views/components/item.ecr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 6c745945..771f5ba1 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -545,7 +545,7 @@ def template_playlist(playlist) playlist["videos"].as_a.each do |video| html += <<-END_HTML
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index b15ae255..d084bfd4 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -48,7 +48,7 @@

    <%= HTML.escape(item.author) %>

    <% when PlaylistVideo %> - + <% if !env.get("preferences").as(Preferences).thin_mode %>
    -- cgit v1.2.3 From 440105976f20fcd63ad3d821b9aa8e0c900f0187 Mon Sep 17 00:00:00 2001 From: diogo Date: Sat, 17 Jul 2021 01:48:33 +0200 Subject: fix cases when high offset video from playlist has no offset in url --- src/invidious/playlists.cr | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 771f5ba1..33623ec2 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -439,7 +439,7 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) else videos = [] of PlaylistVideo - until videos.size >= 50 || videos.size == playlist.video_count + until videos.size >= 50 || videos.size == playlist.video_count || offset >= playlist.video_count if offset >= 100 # Normalize offset to match youtube's behavior (100 videos chunck per request) normalized_offset = (offset / 100).to_i64 * 100_i64 @@ -454,18 +454,24 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) if continuation until videos[0].id == continuation videos.shift + if videos.size == 0 + break + end end - elsif + else until videos[0].index == offset videos.shift + if videos.size == 0 + break + end end end - if offset == 0 + if videos.size > 0 && offset == 0 offset = videos[0].index end - offset += 50 + offset += 100 end return videos -- cgit v1.2.3 From 65e45c407997878d110a159363d7131a7ed24349 Mon Sep 17 00:00:00 2001 From: diogo Date: Sat, 17 Jul 2021 01:50:53 +0200 Subject: linting --- src/invidious/playlists.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 33623ec2..568f3e2e 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -439,7 +439,7 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) else videos = [] of PlaylistVideo - until videos.size >= 50 || videos.size == playlist.video_count || offset >= playlist.video_count + until videos.size >= 50 || videos.size == playlist.video_count || offset >= playlist.video_count if offset >= 100 # Normalize offset to match youtube's behavior (100 videos chunck per request) normalized_offset = (offset / 100).to_i64 * 100_i64 -- cgit v1.2.3 From 0a9e19646afad11bccfba5430ed526178952d479 Mon Sep 17 00:00:00 2001 From: diogo Date: Sat, 17 Jul 2021 19:43:03 +0200 Subject: pass the api/v1/playlists with videos before the offset --- src/invidious/playlists.cr | 7 ++++--- src/invidious/routes/api/v1/misc.cr | 30 +++++++++++++++++++++++++----- 2 files changed, 29 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 568f3e2e..a4ef212f 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -439,7 +439,8 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) else videos = [] of PlaylistVideo - until videos.size >= 50 || videos.size == playlist.video_count || offset >= playlist.video_count + original_offset = offset + until videos.size >= 100 || videos.size == playlist.video_count || offset >= playlist.video_count if offset >= 100 # Normalize offset to match youtube's behavior (100 videos chunck per request) normalized_offset = (offset / 100).to_i64 * 100_i64 @@ -459,7 +460,7 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) end end else - until videos[0].index == offset + until videos[0].index == original_offset videos.shift if videos.size == 0 break @@ -550,7 +551,7 @@ def template_playlist(playlist) playlist["videos"].as_a.each do |video| html += <<-END_HTML -
  • +
  • diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index cf95bd9b..9aa75e09 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -24,7 +24,7 @@ module Invidious::Routes::API::V1::Misc offset ||= env.params.query["page"]?.try &.to_i?.try { |page| (page - 1) * 100 } offset ||= 0 - continuation = env.params.query["continuation"]? + video_id = env.params.query["continuation"]? format = env.params.query["format"]? format ||= "json" @@ -46,12 +46,32 @@ module Invidious::Routes::API::V1::Misc return error_json(404, "Playlist does not exist.") end - response = playlist.to_json(offset, locale, continuation: continuation) + # includes into the playlist a maximum of 20 videos, before the offset + lookback = 20 + if offset > 0 + lookback = offset < lookback ? offset : lookback + response = playlist.to_json(offset - lookback, locale) + json_response = JSON.parse(response) + else + # Unless the continuation is really the offset 0, it becomes expensive. + # It happens when the offset is not set. + # First we find the actual offset, and then we lookback + # it shouldn't happen often though + + response = playlist.to_json(offset, locale, continuation: continuation) + json_response = JSON.parse(response) + + if json_response["videos"].as_a[0]["index"] != offset + offset = json_response["videos"].as_a[0]["index"].as_i + lookback = offset < 50 ? offset : 50 + response = playlist.to_json(offset - lookback, locale) + json_response = JSON.parse(response) + end + end if format == "html" - response = JSON.parse(response) - playlist_html = template_playlist(response) - index, next_video = response["videos"].as_a.skip(1).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} + playlist_html = template_playlist(json_response) + index, next_video = json_response["videos"].as_a.skip(1 + lookback).select { |video| !video["author"].as_s.empty? }[0]?.try { |v| {v["index"], v["videoId"]} } || {nil, nil} response = { "playlistHtml" => playlist_html, -- cgit v1.2.3 From 24bc3e27045c2e93223255b9b383f14879dc8699 Mon Sep 17 00:00:00 2001 From: diogo Date: Sun, 18 Jul 2021 17:43:37 +0300 Subject: no need to normalize the offset --- src/invidious/playlists.cr | 19 +++---------------- 1 file changed, 3 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index a4ef212f..1718bc2f 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -439,17 +439,11 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) else videos = [] of PlaylistVideo - original_offset = offset until videos.size >= 100 || videos.size == playlist.video_count || offset >= playlist.video_count - if offset >= 100 - # Normalize offset to match youtube's behavior (100 videos chunck per request) - normalized_offset = (offset / 100).to_i64 * 100_i64 - ctoken = produce_playlist_continuation(playlist.id, normalized_offset) - initial_data = request_youtube_api_browse(ctoken) - else - initial_data = request_youtube_api_browse("VL" + playlist.id, params: "") - end + # 100 videos per request + ctoken = produce_playlist_continuation(playlist.id, offset) + initial_data = request_youtube_api_browse(ctoken) videos += extract_playlist_videos(initial_data) if continuation @@ -459,13 +453,6 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) break end end - else - until videos[0].index == original_offset - videos.shift - if videos.size == 0 - break - end - end end if videos.size > 0 && offset == 0 -- cgit v1.2.3 From 6176da3cbb3decdab0da1a36003a965f3f752000 Mon Sep 17 00:00:00 2001 From: diogo Date: Sun, 18 Jul 2021 18:05:44 +0300 Subject: linting --- src/invidious/playlists.cr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 1718bc2f..20128462 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -440,7 +440,6 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) videos = [] of PlaylistVideo until videos.size >= 100 || videos.size == playlist.video_count || offset >= playlist.video_count - # 100 videos per request ctoken = produce_playlist_continuation(playlist.id, offset) initial_data = request_youtube_api_browse(ctoken) -- cgit v1.2.3 From 84124b837db306c9bd5575fd6f87c413baed1e5b Mon Sep 17 00:00:00 2001 From: diogo Date: Mon, 19 Jul 2021 12:09:17 +0300 Subject: use v1/next instead of searching for the continuation index --- src/invidious/playlists.cr | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 20128462..b17ff9ef 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -437,6 +437,12 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) 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 + + if continuation + initial_data = request_youtube_api_next(continuation, playlist.id) + offset = initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]["currentIndex"].as_i + end + videos = [] of PlaylistVideo until videos.size >= 100 || videos.size == playlist.video_count || offset >= playlist.video_count @@ -445,19 +451,6 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) initial_data = request_youtube_api_browse(ctoken) videos += extract_playlist_videos(initial_data) - if continuation - until videos[0].id == continuation - videos.shift - if videos.size == 0 - break - end - end - end - - if videos.size > 0 && offset == 0 - offset = videos[0].index - end - offset += 100 end -- cgit v1.2.3 From dccdf38ce725e81778ce3b641a5a537619c774ef Mon Sep 17 00:00:00 2001 From: diogo Date: Mon, 19 Jul 2021 12:09:48 +0300 Subject: increase the max videos in a playlist --- src/invidious/playlists.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index b17ff9ef..ad142dd6 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -445,7 +445,7 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) videos = [] of PlaylistVideo - until videos.size >= 100 || videos.size == playlist.video_count || offset >= playlist.video_count + until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count # 100 videos per request ctoken = produce_playlist_continuation(playlist.id, offset) initial_data = request_youtube_api_browse(ctoken) -- cgit v1.2.3 From 62dc6293375a53c9a4151da3213ecee6a2faa097 Mon Sep 17 00:00:00 2001 From: diogo Date: Mon, 19 Jul 2021 12:15:03 +0300 Subject: linting --- src/invidious/playlists.cr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index ad142dd6..a290ca61 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -437,7 +437,6 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) 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 - if continuation initial_data = request_youtube_api_next(continuation, playlist.id) offset = initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]["currentIndex"].as_i -- cgit v1.2.3 From e3df9f9eaddfa8eb4781512de7d4fd97d1729b10 Mon Sep 17 00:00:00 2001 From: diogo Date: Mon, 19 Jul 2021 17:21:04 +0300 Subject: use dig for getting the video index --- src/invidious/playlists.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index a290ca61..788a4dbc 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -439,7 +439,7 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) else if continuation initial_data = request_youtube_api_next(continuation, playlist.id) - offset = initial_data["contents"]["twoColumnWatchNextResults"]["playlist"]["playlist"]["currentIndex"].as_i + offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset end videos = [] of PlaylistVideo -- cgit v1.2.3 From ee94ccdeb0097bc14dbde9ca945784c3a630347a Mon Sep 17 00:00:00 2001 From: diogo Date: Sun, 8 Aug 2021 19:05:47 +0200 Subject: update to new YoutubeAPI --- src/invidious/playlists.cr | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 788a4dbc..7f80dc11 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -438,7 +438,10 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) playlist.id, playlist.index, offset, as: PlaylistVideo) else if continuation - initial_data = request_youtube_api_next(continuation, playlist.id) + initial_data = YoutubeAPI.next({ + "videoId" => continuation, + "playlistId" => playlist.id, + }) offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset end @@ -447,7 +450,7 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) until videos.size >= 200 || videos.size == playlist.video_count || offset >= playlist.video_count # 100 videos per request ctoken = produce_playlist_continuation(playlist.id, offset) - initial_data = request_youtube_api_browse(ctoken) + initial_data = YoutubeAPI.browse(ctoken) videos += extract_playlist_videos(initial_data) offset += 100 -- cgit v1.2.3 From c4c8a1050769a4ab25ab57ba62b96cf2b30fa6f9 Mon Sep 17 00:00:00 2001 From: diogo Date: Mon, 9 Aug 2021 09:36:44 +0200 Subject: rename from continuation to video_id on get_playlist_videos --- src/invidious/playlists.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 7f80dc11..31b48fe8 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -426,7 +426,7 @@ def fetch_playlist(plid, locale) }) end -def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) +def get_playlist_videos(db, playlist, offset, locale = nil, video_id = nil) # Show empy playlist if requested page is out of range # (e.g, when a new playlist has been created, offset will be negative) if offset >= playlist.video_count || offset < 0 @@ -437,9 +437,9 @@ def get_playlist_videos(db, playlist, offset, locale = nil, continuation = nil) 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 - if continuation + if video_id initial_data = YoutubeAPI.next({ - "videoId" => continuation, + "videoId" => video_id, "playlistId" => playlist.id, }) offset = initial_data.dig?("contents", "twoColumnWatchNextResults", "playlist", "playlist", "currentIndex").try &.as_i || offset -- cgit v1.2.3 From a1d6411f1f7232c98a17e9d3f17d61a771068780 Mon Sep 17 00:00:00 2001 From: diogo Date: Mon, 9 Aug 2021 09:47:37 +0200 Subject: propagate video_id field on getting playlists --- src/invidious/playlists.cr | 22 +++++++++++----------- src/invidious/routes/api/v1/misc.cr | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 31b48fe8..5034844e 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -107,7 +107,7 @@ struct Playlist property updated : Time property thumbnail : String? - def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder, video_id : String? = nil) json.object do json.field "type", "playlist" json.field "title", self.title @@ -142,7 +142,7 @@ struct Playlist json.field "videos" do json.array do - videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) + videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id) videos.each_with_index do |video, index| video.to_json(locale, json) end @@ -151,12 +151,12 @@ struct Playlist end end - def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder? = nil, video_id : String? = nil) if json - to_json(offset, locale, json, continuation: continuation) + to_json(offset, locale, json, video_id: video_id) else JSON.build do |json| - to_json(offset, locale, json, continuation: continuation) + to_json(offset, locale, json, video_id: video_id) end end end @@ -196,7 +196,7 @@ struct InvidiousPlaylist end end - def to_json(offset, locale, json : JSON::Builder, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder, video_id : String? = nil) json.object do json.field "type", "invidiousPlaylist" json.field "title", self.title @@ -218,11 +218,11 @@ struct InvidiousPlaylist json.field "videos" do json.array do if !offset || offset == 0 - index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, continuation, as: Int64) + index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, video_id, as: Int64) offset = self.index.index(index) || 0 end - videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, continuation: continuation) + videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id) videos.each_with_index do |video, index| video.to_json(locale, json, offset + index) end @@ -231,12 +231,12 @@ struct InvidiousPlaylist end end - def to_json(offset, locale, json : JSON::Builder? = nil, continuation : String? = nil) + def to_json(offset, locale, json : JSON::Builder? = nil, video_id : String? = nil) if json - to_json(offset, locale, json, continuation: continuation) + to_json(offset, locale, json, video_id: video_id) else JSON.build do |json| - to_json(offset, locale, json, continuation: continuation) + to_json(offset, locale, json, video_id: video_id) end end end diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 9aa75e09..da5a940c 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -58,7 +58,7 @@ module Invidious::Routes::API::V1::Misc # First we find the actual offset, and then we lookback # it shouldn't happen often though - response = playlist.to_json(offset, locale, continuation: continuation) + response = playlist.to_json(offset, locale, video_id: video_id) json_response = JSON.parse(response) if json_response["videos"].as_a[0]["index"] != offset -- cgit v1.2.3 From 678b10dbcfc71fd9001915892586de8c62357976 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 11 Oct 2021 23:52:57 +0200 Subject: Lookback 50 videos --- src/invidious/routes/api/v1/misc.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index da5a940c..80b59fd5 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -47,9 +47,8 @@ module Invidious::Routes::API::V1::Misc end # includes into the playlist a maximum of 20 videos, before the offset - lookback = 20 if offset > 0 - lookback = offset < lookback ? offset : lookback + lookback = offset < 50 ? offset : 50 response = playlist.to_json(offset - lookback, locale) json_response = JSON.parse(response) else @@ -58,6 +57,7 @@ module Invidious::Routes::API::V1::Misc # First we find the actual offset, and then we lookback # it shouldn't happen often though + lookback = 0 response = playlist.to_json(offset, locale, video_id: video_id) json_response = JSON.parse(response) -- cgit v1.2.3 From c44c1003affd73d7349a0d8c7a09fa29000e4cf4 Mon Sep 17 00:00:00 2001 From: Frank de Lange Date: Tue, 12 Oct 2021 14:56:15 +0000 Subject: Handle missing title fields in VideoRendererParser.parse --- src/invidious/helpers/extractors.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index c8a6cd4a..1ebbe889 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -43,7 +43,7 @@ private module Parsers private def self.parse(item_contents, author_fallback) video_id = item_contents["videoId"].as_s - title = extract_text(item_contents["title"]) || "" + title = extract_text(item_contents["title"]?) || "" # Extract author information if author_info = item_contents.dig?("ownerText", "runs", 0) -- cgit v1.2.3 From 3dc980e80051cf1d5dc4a224fb57823612c4bc94 Mon Sep 17 00:00:00 2001 From: Frank de Lange Date: Tue, 12 Oct 2021 20:17:45 +0200 Subject: Fix for #2488 - parse contents of search results of type=Category (#2496) * Fix for #2488 - parse contents of search results of type=Category (returned on first page for universal (type=all) queries instead of returning an error. * Moved content array walker to Category#to_json As requested by reviewer this change moves the content array walker from the API endpoint to the Category class. * Update src/invidious/helpers/serialized_yt_data.cr Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com> --- src/invidious/helpers/serialized_yt_data.cr | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 61356555..a9798f0c 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -237,8 +237,15 @@ class Category def to_json(locale, json : JSON::Builder) json.object do + json.field "type", "category" json.field "title", self.title - json.field "contents", self.contents + json.field "contents" do + json.array do + self.contents.each do |item| + item.to_json(locale, json) + end + end + end end end -- cgit v1.2.3 From b49b5fbda90a7fc9923af27a870504be976d55e9 Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 13 Oct 2021 11:32:27 -0700 Subject: Support empty categories --- src/invidious/helpers/extractors.cr | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index c8a6cd4a..73b07fd2 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -321,11 +321,13 @@ private module Parsers content_container = item_contents["contents"] end - raw_contents = content_container["items"].as_a - raw_contents.each do |item| - result = extract_item(item) - if !result.nil? - contents << result + raw_contents = content_container["items"]?.try &.as_a + if !raw_contents.nil? + raw_contents.each do |item| + result = extract_item(item) + if !result.nil? + contents << result + end end end -- cgit v1.2.3 From 4d44b2c3a422abb3e989a9010c082cfb6a52fb12 Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 13 Oct 2021 11:33:04 -0700 Subject: Handle YT tabs without any content --- src/invidious/helpers/extractors.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr index 73b07fd2..6af40de5 100644 --- a/src/invidious/helpers/extractors.cr +++ b/src/invidious/helpers/extractors.cr @@ -401,7 +401,7 @@ private module Extractors items_container = renderer_container_contents end - items_container["items"].as_a.each do |item| + items_container["items"]?.try &.as_a.each do |item| raw_items << item end end -- cgit v1.2.3 From 585e4617e85abf057b6ba50750d295d0c6e2458c Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 14 Oct 2021 08:18:44 -0700 Subject: Lazy load (some) images --- src/invidious/comments.cr | 6 +++--- src/invidious/mixes.cr | 2 +- src/invidious/playlists.cr | 2 +- src/invidious/views/components/item.ecr | 10 +++++----- src/invidious/views/watch.ecr | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index a5506b03..9c788253 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -329,7 +329,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML
    - +

    @@ -349,7 +349,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML

    - +
    END_HTML @@ -410,7 +410,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML
    - +
    diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 55b01174..63ea434f 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -97,7 +97,7 @@ def template_mix(mix)
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    #{video["title"]}

    diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 5034844e..7940dc1f 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -535,7 +535,7 @@ def template_playlist(playlist)
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    #{video["title"]}

    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index d084bfd4..5788bf51 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -5,7 +5,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - "/> + "/>
    <% end %>

    <%= HTML.escape(item.author) %>

    @@ -23,7 +23,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - "/> + "/>

    <%= number_with_separator(item.video_count) %> videos

    <% end %> @@ -36,7 +36,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> @@ -51,7 +51,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if plid = env.get?("remove_playlist_items") %> " method="post"> "> @@ -114,7 +114,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if env.get? "show_watched" %> " method="post"> "> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 68e7eb80..398e25b6 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -303,7 +303,7 @@ we're going to need to do it here in order to allow for translations. "> <% if !env.get("preferences").as(Preferences).thin_mode %>
    - /mqdefault.jpg"> + /mqdefault.jpg">

    <%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %>

    <% end %> -- cgit v1.2.3 From 0ad2793b682d9b96e41feee32dec3b8e536466d8 Mon Sep 17 00:00:00 2001 From: TheFrenchGhosty Date: Thu, 14 Oct 2021 20:20:25 +0200 Subject: Link to invidious.io/donate/ in the footer --- src/invidious/views/template.ecr | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index b7020598..ffa03a8a 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -149,9 +149,7 @@
    - <%= translate(locale, "footer_donate") %> - BTC / - XMR + <%= translate(locale, "footer_donate") %> <%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %>
    -- cgit v1.2.3 From 22e8f7e287cfc5d2680fd97b72e0e19fd47b6d24 Mon Sep 17 00:00:00 2001 From: TheFrenchGhosty Date: Thu, 14 Oct 2021 21:00:14 +0200 Subject: Fix #2510 --- src/invidious/views/template.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index ffa03a8a..3fb2fe18 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -149,7 +149,7 @@
    - <%= translate(locale, "footer_donate") %> + <%= translate(locale, "footer_donate_page") %> <%= translate(locale, "Current version: ") %> <%= CURRENT_VERSION %>-<%= CURRENT_COMMIT %> @ <%= CURRENT_BRANCH %>
    -- cgit v1.2.3 From 56dbe159435d643499ac875fe2c4283be8e61b84 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Oct 2021 22:16:50 +0200 Subject: Move proxy & YoutubeAPI code to the yt_backend folder --- src/invidious/helpers/proxy.cr | 316 ---------------------- src/invidious/helpers/youtube_api.cr | 447 -------------------------------- src/invidious/yt_backend/proxy.cr | 316 ++++++++++++++++++++++ src/invidious/yt_backend/youtube_api.cr | 447 ++++++++++++++++++++++++++++++++ 4 files changed, 763 insertions(+), 763 deletions(-) delete mode 100644 src/invidious/helpers/proxy.cr delete mode 100644 src/invidious/helpers/youtube_api.cr create mode 100644 src/invidious/yt_backend/proxy.cr create mode 100644 src/invidious/yt_backend/youtube_api.cr (limited to 'src') diff --git a/src/invidious/helpers/proxy.cr b/src/invidious/helpers/proxy.cr deleted file mode 100644 index 3418d887..00000000 --- a/src/invidious/helpers/proxy.cr +++ /dev/null @@ -1,316 +0,0 @@ -# See https://github.com/crystal-lang/crystal/issues/2963 -class HTTPProxy - getter proxy_host : String - getter proxy_port : Int32 - getter options : Hash(Symbol, String) - getter tls : OpenSSL::SSL::Context::Client? - - def initialize(@proxy_host, @proxy_port = 80, @options = {} of Symbol => String) - end - - def open(host, port, tls = nil, connection_options = {} of Symbol => Float64 | Nil) - dns_timeout = connection_options.fetch(:dns_timeout, nil) - connect_timeout = connection_options.fetch(:connect_timeout, nil) - read_timeout = connection_options.fetch(:read_timeout, nil) - - socket = TCPSocket.new @proxy_host, @proxy_port, dns_timeout, connect_timeout - socket.read_timeout = read_timeout if read_timeout - socket.sync = true - - socket << "CONNECT #{host}:#{port} HTTP/1.1\r\n" - - if options[:user]? - credentials = Base64.strict_encode("#{options[:user]}:#{options[:password]}") - credentials = "#{credentials}\n".gsub(/\s/, "") - socket << "Proxy-Authorization: Basic #{credentials}\r\n" - end - - socket << "\r\n" - - resp = parse_response(socket) - - if resp[:code]? == 200 - {% if !flag?(:without_openssl) %} - if tls - tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host) - socket = tls_socket - end - {% end %} - - return socket - else - socket.close - raise IO::Error.new(resp.inspect) - end - end - - private def parse_response(socket) - resp = {} of Symbol => Int32 | String | Hash(String, String) - - begin - version, code, reason = socket.gets.as(String).chomp.split(/ /, 3) - - headers = {} of String => String - - while (line = socket.gets.as(String)) && (line.chomp != "") - name, value = line.split(/:/, 2) - headers[name.strip] = value.strip - end - - resp[:version] = version - resp[:code] = code.to_i - resp[:reason] = reason - resp[:headers] = headers - rescue - end - - return resp - end -end - -class HTTPClient < HTTP::Client - def set_proxy(proxy : HTTPProxy) - begin - @io = proxy.open(host: @host, port: @port, tls: @tls, connection_options: proxy_connection_options) - rescue IO::Error - @io = nil - end - end - - def unset_proxy - @io = nil - end - - def proxy_connection_options - opts = {} of Symbol => Float64 | Nil - - opts[:dns_timeout] = @dns_timeout - opts[:connect_timeout] = @connect_timeout - opts[:read_timeout] = @read_timeout - - return opts - end -end - -def get_proxies(country_code = "US") - # return get_spys_proxies(country_code) - return get_nova_proxies(country_code) -end - -def filter_proxies(proxies) - proxies.select! do |proxy| - begin - client = HTTPClient.new(YT_URL) - client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - client.read_timeout = 10.seconds - client.connect_timeout = 10.seconds - - proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) - client.set_proxy(proxy) - - status_ok = client.head("/").status_code == 200 - client.close - status_ok - rescue ex - false - end - end - - return proxies -end - -def get_nova_proxies(country_code = "US") - country_code = country_code.downcase - client = HTTP::Client.new(URI.parse("https://www.proxynova.com")) - client.read_timeout = 10.seconds - client.connect_timeout = 10.seconds - - headers = HTTP::Headers.new - headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" - headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" - headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" - headers["Host"] = "www.proxynova.com" - headers["Origin"] = "https://www.proxynova.com" - headers["Referer"] = "https://www.proxynova.com/proxy-server-list/country-#{country_code}/" - - response = client.get("/proxy-server-list/country-#{country_code}/", headers) - client.close - document = XML.parse_html(response.body) - - proxies = [] of {ip: String, port: Int32, score: Float64} - document.xpath_nodes(%q(//tr[@data-proxy-id])).each do |node| - ip = node.xpath_node(%q(.//td/abbr/script)).not_nil!.content - ip = ip.match(/document\.write\('(?[^']+)'.substr\(8\) \+ '(?[^']+)'/).not_nil! - ip = "#{ip["sub1"][8..-1]}#{ip["sub2"]}" - port = node.xpath_node(%q(.//td[2])).not_nil!.content.strip.to_i - - anchor = node.xpath_node(%q(.//td[4]/div)).not_nil! - speed = anchor["data-value"].to_f - latency = anchor["title"].to_f - uptime = node.xpath_node(%q(.//td[5]/span)).not_nil!.content.rchop("%").to_f - - # TODO: Tweak me - score = (uptime*4 + speed*2 + latency)/7 - proxies << {ip: ip, port: port, score: score} - end - - # proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse - return proxies -end - -def get_spys_proxies(country_code = "US") - client = HTTP::Client.new(URI.parse("http://spys.one")) - client.read_timeout = 10.seconds - client.connect_timeout = 10.seconds - - headers = HTTP::Headers.new - headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" - headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" - headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" - headers["Host"] = "spys.one" - headers["Origin"] = "http://spys.one" - headers["Referer"] = "http://spys.one/free-proxy-list/#{country_code}/" - headers["Content-Type"] = "application/x-www-form-urlencoded" - body = { - "xpp" => "5", - "xf1" => "0", - "xf2" => "0", - "xf4" => "0", - "xf5" => "1", - } - - response = client.post("/free-proxy-list/#{country_code}/", headers, form: body) - client.close - 20.times do - if response.status_code == 200 - break - end - response = client.post("/free-proxy-list/#{country_code}/", headers, form: body) - end - - response = XML.parse_html(response.body) - - mapping = response.xpath_node(%q(.//body/script)).not_nil!.content - mapping = mapping.match(/\}\('(?

    [^']+)',\d+,\d+,'(?[^']+)'/).not_nil! - p = mapping["p"].not_nil! - x = mapping["x"].not_nil! - mapping = decrypt_port(p, x) - - proxies = [] of {ip: String, port: Int32, score: Float64} - response = response.xpath_node(%q(//tr/td/table)).not_nil! - response.xpath_nodes(%q(.//tr)).each do |node| - if !node["onmouseover"]? - next - end - - ip = node.xpath_node(%q(.//td[1]/font[2])).to_s.match(/(?

    [^<]+)"\+(?[\d\D]+)\)$/).not_nil!["encrypted_port"] - - port = "" - encrypted_port.split("+").each do |number| - number = number.delete("()") - left_side, right_side = number.split("^") - result = mapping[left_side] ^ mapping[right_side] - port = "#{port}#{result}" - end - port = port.to_i - - latency = node.xpath_node(%q(.//td[6])).not_nil!.content.to_f - speed = node.xpath_node(%q(.//td[7]/font/table)).not_nil!["width"].to_f - uptime = node.xpath_node(%q(.//td[8]/font/acronym)).not_nil! - - # Skip proxies that are down - if uptime["title"].ends_with? "?" - next - end - - if md = uptime.content.match(/^\d+/) - uptime = md[0].to_f - else - next - end - - score = (uptime*4 + speed*2 + latency)/7 - - proxies << {ip: ip, port: port, score: score} - end - - proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse - return proxies -end - -def decrypt_port(p, x) - x = x.split("^") - s = {} of String => String - - 60.times do |i| - if x[i]?.try &.empty? - s[y_func(i)] = y_func(i) - else - s[y_func(i)] = x[i] - end - end - - x = s - p = p.gsub(/\b\w+\b/, x) - - p = p.split(";") - p = p.map { |item| item.split("=") } - - mapping = {} of String => Int32 - p.each do |item| - if item == [""] - next - end - - key = item[0] - value = item[1] - value = value.split("^") - - if value.size == 1 - value = value[0].to_i - else - left_side = value[0].to_i? - left_side ||= mapping[value[0]] - right_side = value[1].to_i? - right_side ||= mapping[value[1]] - - value = left_side ^ right_side - end - - mapping[key] = value - end - - return mapping -end - -def y_func(c) - return (c < 60 ? "" : y_func((c/60).to_i)) + ((c = c % 60) > 35 ? ((c.to_u8 + 29).unsafe_chr) : c.to_s(36)) -end - -PROXY_LIST = { - "GB" => [{ip: "147.135.206.233", port: 3128}, {ip: "167.114.180.102", port: 8080}, {ip: "176.35.250.108", port: 8080}, {ip: "5.148.128.44", port: 80}, {ip: "62.7.85.234", port: 8080}, {ip: "88.150.135.10", port: 36624}], - "DE" => [{ip: "138.201.223.250", port: 31288}, {ip: "138.68.73.59", port: 32574}, {ip: "159.69.211.173", port: 3128}, {ip: "173.249.43.105", port: 3128}, {ip: "212.202.244.90", port: 8080}, {ip: "5.56.18.35", port: 38827}], - "FR" => [{ip: "137.74.254.242", port: 3128}, {ip: "151.80.143.155", port: 53281}, {ip: "178.33.150.97", port: 3128}, {ip: "37.187.2.31", port: 3128}, {ip: "5.135.164.72", port: 3128}, {ip: "5.39.91.73", port: 3128}, {ip: "51.38.162.2", port: 32231}, {ip: "51.38.217.121", port: 808}, {ip: "51.75.109.81", port: 3128}, {ip: "51.75.109.82", port: 3128}, {ip: "51.75.109.83", port: 3128}, {ip: "51.75.109.84", port: 3128}, {ip: "51.75.109.86", port: 3128}, {ip: "51.75.109.88", port: 3128}, {ip: "51.75.109.90", port: 3128}, {ip: "62.210.167.3", port: 3128}, {ip: "90.63.218.232", port: 8080}, {ip: "91.134.165.198", port: 9999}], - "IN" => [{ip: "1.186.151.206", port: 36253}, {ip: "1.186.63.130", port: 39142}, {ip: "103.105.40.1", port: 16538}, {ip: "103.105.40.153", port: 16538}, {ip: "103.106.148.203", port: 60227}, {ip: "103.106.148.207", port: 51451}, {ip: "103.12.246.12", port: 8080}, {ip: "103.14.235.109", port: 8080}, {ip: "103.14.235.26", port: 8080}, {ip: "103.198.172.4", port: 50820}, {ip: "103.205.112.1", port: 23500}, {ip: "103.209.64.19", port: 6666}, {ip: "103.211.76.5", port: 8080}, {ip: "103.216.82.19", port: 6666}, {ip: "103.216.82.190", port: 6666}, {ip: "103.216.82.209", port: 54806}, {ip: "103.216.82.214", port: 6666}, {ip: "103.216.82.37", port: 6666}, {ip: "103.216.82.44", port: 8080}, {ip: "103.216.82.50", port: 53281}, {ip: "103.22.173.230", port: 8080}, {ip: "103.224.38.2", port: 83}, {ip: "103.226.142.90", port: 41386}, {ip: "103.236.114.38", port: 49638}, {ip: "103.240.161.107", port: 6666}, {ip: "103.240.161.108", port: 6666}, {ip: "103.240.161.109", port: 6666}, {ip: "103.240.161.59", port: 48809}, {ip: "103.245.198.101", port: 8080}, {ip: "103.250.148.82", port: 6666}, {ip: "103.251.58.51", port: 61489}, {ip: "103.253.169.115", port: 32731}, {ip: "103.253.211.182", port: 8080}, {ip: "103.253.211.182", port: 80}, {ip: "103.255.234.169", port: 39847}, {ip: "103.42.161.118", port: 8080}, {ip: "103.42.162.30", port: 8080}, {ip: "103.42.162.50", port: 8080}, {ip: "103.42.162.58", port: 8080}, {ip: "103.46.233.12", port: 83}, {ip: "103.46.233.13", port: 83}, {ip: "103.46.233.16", port: 83}, {ip: "103.46.233.17", port: 83}, {ip: "103.46.233.21", port: 83}, {ip: "103.46.233.23", port: 83}, {ip: "103.46.233.29", port: 81}, {ip: "103.46.233.29", port: 83}, {ip: "103.46.233.50", port: 83}, {ip: "103.47.153.87", port: 8080}, {ip: "103.47.66.2", port: 39804}, {ip: "103.49.53.1", port: 81}, {ip: "103.52.220.1", port: 49068}, {ip: "103.56.228.166", port: 53281}, {ip: "103.56.30.128", port: 8080}, {ip: "103.65.193.17", port: 50862}, {ip: "103.65.195.1", port: 33960}, {ip: "103.69.220.14", port: 3128}, {ip: "103.70.128.84", port: 8080}, {ip: "103.70.128.86", port: 8080}, {ip: "103.70.131.74", port: 8080}, {ip: "103.70.146.250", port: 59563}, {ip: "103.72.216.194", port: 38345}, {ip: "103.75.161.38", port: 21776}, {ip: "103.76.253.155", port: 3128}, {ip: "103.87.104.137", port: 8080}, {ip: "110.235.198.3", port: 57660}, {ip: "114.69.229.161", port: 8080}, {ip: "117.196.231.201", port: 37769}, {ip: "117.211.166.214", port: 3128}, {ip: "117.240.175.51", port: 3128}, {ip: "117.240.210.155", port: 53281}, {ip: "117.240.59.115", port: 36127}, {ip: "117.242.154.73", port: 33889}, {ip: "117.244.15.243", port: 3128}, {ip: "119.235.54.3", port: 8080}, {ip: "120.138.117.102", port: 59308}, {ip: "123.108.200.185", port: 83}, {ip: "123.108.200.217", port: 82}, {ip: "123.176.43.218", port: 40524}, {ip: "125.21.43.82", port: 8080}, {ip: "125.62.192.225", port: 82}, {ip: "125.62.192.33", port: 84}, {ip: "125.62.194.1", port: 83}, {ip: "125.62.213.134", port: 82}, {ip: "125.62.213.18", port: 83}, {ip: "125.62.213.201", port: 84}, {ip: "125.62.213.242", port: 83}, {ip: "125.62.214.185", port: 84}, {ip: "139.5.26.27", port: 53281}, {ip: "14.102.67.101", port: 30337}, {ip: "14.142.122.134", port: 8080}, {ip: "150.129.114.194", port: 6666}, {ip: "150.129.151.62", port: 6666}, {ip: "150.129.171.115", port: 6666}, {ip: "150.129.201.30", port: 6666}, {ip: "157.119.207.38", port: 53281}, {ip: "175.100.185.151", port: 53281}, {ip: "182.18.177.114", port: 56173}, {ip: "182.73.194.170", port: 8080}, {ip: "182.74.85.230", port: 51214}, {ip: "183.82.116.56", port: 8080}, {ip: "183.82.32.56", port: 49551}, {ip: "183.87.14.229", port: 53281}, {ip: "183.87.14.250", port: 44915}, {ip: "202.134.160.168", port: 8080}, {ip: "202.134.166.1", port: 8080}, {ip: "202.134.180.50", port: 8080}, {ip: "202.62.84.210", port: 53281}, {ip: "203.192.193.225", port: 8080}, {ip: "203.192.195.14", port: 31062}, {ip: "203.192.217.11", port: 8080}, {ip: "223.196.83.182", port: 53281}, {ip: "27.116.20.169", port: 36630}, {ip: "27.116.20.209", port: 36630}, {ip: "27.116.51.21", port: 36033}, {ip: "43.224.8.114", port: 50333}, {ip: "43.224.8.116", port: 6666}, {ip: "43.224.8.124", port: 6666}, {ip: "43.224.8.86", port: 6666}, {ip: "43.225.20.73", port: 8080}, {ip: "43.225.23.26", port: 8080}, {ip: "43.230.196.98", port: 36569}, {ip: "43.240.5.225", port: 31777}, {ip: "43.241.28.248", port: 8080}, {ip: "43.242.209.201", port: 8080}, {ip: "43.246.139.82", port: 8080}, {ip: "43.248.73.86", port: 53281}, {ip: "43.251.170.145", port: 54059}, {ip: "45.112.57.230", port: 61222}, {ip: "45.115.171.30", port: 47949}, {ip: "45.121.29.254", port: 54858}, {ip: "45.123.26.146", port: 53281}, {ip: "45.125.61.193", port: 32804}, {ip: "45.125.61.209", port: 32804}, {ip: "45.127.121.194", port: 53281}, {ip: "45.250.226.14", port: 3128}, {ip: "45.250.226.38", port: 8080}, {ip: "45.250.226.47", port: 8080}, {ip: "45.250.226.55", port: 8080}, {ip: "49.249.251.86", port: 53281}], - "CN" => [{ip: "182.61.170.45", port: 3128}], - "RU" => [{ip: "109.106.139.225", port: 45689}, {ip: "109.161.48.228", port: 53281}, {ip: "109.167.224.198", port: 51919}, {ip: "109.172.57.250", port: 23500}, {ip: "109.194.2.126", port: 61822}, {ip: "109.195.150.128", port: 37564}, {ip: "109.201.96.171", port: 31773}, {ip: "109.201.97.204", port: 41258}, {ip: "109.201.97.235", port: 39125}, {ip: "109.206.140.74", port: 45991}, {ip: "109.206.148.31", port: 30797}, {ip: "109.69.75.5", port: 46347}, {ip: "109.71.181.170", port: 53983}, {ip: "109.74.132.190", port: 42663}, {ip: "109.74.143.45", port: 36529}, {ip: "109.75.140.158", port: 59916}, {ip: "109.95.84.114", port: 52125}, {ip: "130.255.12.24", port: 31004}, {ip: "134.19.147.72", port: 44812}, {ip: "134.90.181.7", port: 54353}, {ip: "145.255.6.171", port: 31252}, {ip: "146.120.227.3", port: 8080}, {ip: "149.255.112.194", port: 48968}, {ip: "158.46.127.222", port: 52574}, {ip: "158.46.43.144", port: 39120}, {ip: "158.58.130.185", port: 50016}, {ip: "158.58.132.12", port: 56962}, {ip: "158.58.133.106", port: 41258}, {ip: "158.58.133.13", port: 21213}, {ip: "176.101.0.47", port: 34471}, {ip: "176.101.89.226", port: 33470}, {ip: "176.106.12.65", port: 30120}, {ip: "176.107.80.110", port: 58901}, {ip: "176.110.121.9", port: 46322}, {ip: "176.110.121.90", port: 21776}, {ip: "176.111.97.18", port: 8080}, {ip: "176.112.106.230", port: 33996}, {ip: "176.112.110.40", port: 61142}, {ip: "176.113.116.70", port: 55589}, {ip: "176.113.27.192", port: 47337}, {ip: "176.115.197.118", port: 8080}, {ip: "176.117.255.182", port: 53100}, {ip: "176.120.200.69", port: 44331}, {ip: "176.124.123.93", port: 41258}, {ip: "176.192.124.98", port: 60787}, {ip: "176.192.5.238", port: 61227}, {ip: "176.192.8.206", port: 39422}, {ip: "176.193.15.94", port: 8080}, {ip: "176.196.195.170", port: 48129}, {ip: "176.196.198.154", port: 35252}, {ip: "176.196.238.234", port: 44648}, {ip: "176.196.239.46", port: 35656}, {ip: "176.196.246.6", port: 53281}, {ip: "176.196.84.138", port: 51336}, {ip: "176.197.145.246", port: 32649}, {ip: "176.197.99.142", port: 47278}, {ip: "176.215.1.108", port: 60339}, {ip: "176.215.170.147", port: 35604}, {ip: "176.56.23.14", port: 35340}, {ip: "176.62.185.54", port: 53883}, {ip: "176.74.13.110", port: 8080}, {ip: "178.130.29.226", port: 53295}, {ip: "178.170.254.178", port: 46788}, {ip: "178.213.13.136", port: 53281}, {ip: "178.218.104.8", port: 49707}, {ip: "178.219.183.163", port: 8080}, {ip: "178.237.180.34", port: 57307}, {ip: "178.57.101.212", port: 38020}, {ip: "178.57.101.235", port: 31309}, {ip: "178.64.190.133", port: 46688}, {ip: "178.75.1.111", port: 50411}, {ip: "178.75.27.131", port: 41879}, {ip: "185.13.35.178", port: 40654}, {ip: "185.15.189.67", port: 30215}, {ip: "185.175.119.137", port: 41258}, {ip: "185.18.111.194", port: 41258}, {ip: "185.19.176.237", port: 53281}, {ip: "185.190.40.115", port: 31747}, {ip: "185.216.195.134", port: 61287}, {ip: "185.22.172.94", port: 10010}, {ip: "185.22.172.94", port: 1448}, {ip: "185.22.174.65", port: 10010}, {ip: "185.22.174.65", port: 1448}, {ip: "185.23.64.100", port: 3130}, {ip: "185.23.82.39", port: 59248}, {ip: "185.233.94.105", port: 59288}, {ip: "185.233.94.146", port: 57736}, {ip: "185.3.68.54", port: 53500}, {ip: "185.32.120.177", port: 60724}, {ip: "185.34.20.164", port: 53700}, {ip: "185.34.23.43", port: 63238}, {ip: "185.51.60.141", port: 39935}, {ip: "185.61.92.228", port: 33060}, {ip: "185.61.93.67", port: 49107}, {ip: "185.7.233.66", port: 53504}, {ip: "185.72.225.10", port: 56285}, {ip: "185.75.5.158", port: 60819}, {ip: "185.9.86.186", port: 39345}, {ip: "188.133.136.10", port: 47113}, {ip: "188.168.75.254", port: 56899}, {ip: "188.170.41.6", port: 60332}, {ip: "188.187.189.142", port: 38264}, {ip: "188.234.151.103", port: 8080}, {ip: "188.235.11.88", port: 57143}, {ip: "188.235.137.196", port: 23500}, {ip: "188.244.175.2", port: 8080}, {ip: "188.255.82.136", port: 53281}, {ip: "188.43.4.117", port: 60577}, {ip: "188.68.95.166", port: 41258}, {ip: "188.92.242.180", port: 52048}, {ip: "188.93.242.213", port: 49774}, {ip: "192.162.193.243", port: 36910}, {ip: "192.162.214.11", port: 41258}, {ip: "193.106.170.133", port: 38591}, {ip: "193.232.113.244", port: 40412}, {ip: "193.232.234.130", port: 61932}, {ip: "193.242.177.105", port: 53281}, {ip: "193.242.178.50", port: 52376}, {ip: "193.242.178.90", port: 8080}, {ip: "193.33.101.152", port: 34611}, {ip: "194.114.128.149", port: 61213}, {ip: "194.135.15.146", port: 59328}, {ip: "194.135.216.178", port: 56805}, {ip: "194.135.75.74", port: 41258}, {ip: "194.146.201.67", port: 53281}, {ip: "194.186.18.46", port: 56408}, {ip: "194.186.20.62", port: 21231}, {ip: "194.190.171.214", port: 43960}, {ip: "194.9.27.82", port: 42720}, {ip: "195.133.232.58", port: 41733}, {ip: "195.14.114.116", port: 59530}, {ip: "195.14.114.24", port: 56897}, {ip: "195.158.250.97", port: 41582}, {ip: "195.16.48.142", port: 36083}, {ip: "195.191.183.169", port: 47238}, {ip: "195.206.45.112", port: 53281}, {ip: "195.208.172.70", port: 8080}, {ip: "195.209.141.67", port: 31927}, {ip: "195.209.176.2", port: 8080}, {ip: "195.210.144.166", port: 30088}, {ip: "195.211.160.88", port: 44464}, {ip: "195.218.144.182", port: 31705}, {ip: "195.46.168.147", port: 8080}, {ip: "195.9.188.78", port: 53281}, {ip: "195.9.209.10", port: 35242}, {ip: "195.9.223.246", port: 52098}, {ip: "195.9.237.66", port: 8080}, {ip: "195.9.91.66", port: 33199}, {ip: "195.91.132.20", port: 19600}, {ip: "195.98.183.82", port: 30953}, {ip: "212.104.82.246", port: 36495}, {ip: "212.119.229.18", port: 33852}, {ip: "212.13.97.122", port: 30466}, {ip: "212.19.21.19", port: 53264}, {ip: "212.19.5.157", port: 58442}, {ip: "212.19.8.223", port: 30281}, {ip: "212.19.8.239", port: 55602}, {ip: "212.192.202.207", port: 4550}, {ip: "212.22.80.224", port: 34822}, {ip: "212.26.247.178", port: 38418}, {ip: "212.33.228.161", port: 37971}, {ip: "212.33.243.83", port: 38605}, {ip: "212.34.53.126", port: 44369}, {ip: "212.5.107.81", port: 56481}, {ip: "212.7.230.7", port: 51405}, {ip: "212.77.138.161", port: 41258}, {ip: "213.108.221.201", port: 32800}, {ip: "213.109.7.135", port: 59918}, {ip: "213.128.9.204", port: 35549}, {ip: "213.134.196.12", port: 38723}, {ip: "213.168.37.86", port: 8080}, {ip: "213.187.118.184", port: 53281}, {ip: "213.21.23.98", port: 53281}, {ip: "213.210.67.166", port: 53281}, {ip: "213.234.0.242", port: 56503}, {ip: "213.247.192.131", port: 41258}, {ip: "213.251.226.208", port: 56900}, {ip: "213.33.155.80", port: 44387}, {ip: "213.33.199.194", port: 36411}, {ip: "213.33.224.82", port: 8080}, {ip: "213.59.153.19", port: 53281}, {ip: "217.10.45.103", port: 8080}, {ip: "217.107.197.39", port: 33628}, {ip: "217.116.60.66", port: 21231}, {ip: "217.195.87.58", port: 41258}, {ip: "217.197.239.54", port: 34463}, {ip: "217.74.161.42", port: 34175}, {ip: "217.8.84.76", port: 46378}, {ip: "31.131.67.14", port: 8080}, {ip: "31.132.127.142", port: 35432}, {ip: "31.132.218.252", port: 32423}, {ip: "31.173.17.118", port: 51317}, {ip: "31.193.124.70", port: 53281}, {ip: "31.210.211.147", port: 8080}, {ip: "31.220.183.217", port: 53281}, {ip: "31.29.212.82", port: 35066}, {ip: "31.42.254.24", port: 30912}, {ip: "31.47.189.14", port: 38473}, {ip: "37.113.129.98", port: 41665}, {ip: "37.192.103.164", port: 34835}, {ip: "37.192.194.50", port: 50165}, {ip: "37.192.99.151", port: 51417}, {ip: "37.205.83.91", port: 35888}, {ip: "37.233.85.155", port: 53281}, {ip: "37.235.167.66", port: 53281}, {ip: "37.235.65.2", port: 47816}, {ip: "37.235.67.178", port: 34450}, {ip: "37.9.134.133", port: 41262}, {ip: "46.150.174.90", port: 53281}, {ip: "46.151.156.198", port: 56013}, {ip: "46.16.226.10", port: 8080}, {ip: "46.163.131.55", port: 48306}, {ip: "46.173.191.51", port: 53281}, {ip: "46.174.222.61", port: 34977}, {ip: "46.180.96.79", port: 42319}, {ip: "46.181.151.79", port: 39386}, {ip: "46.21.74.130", port: 8080}, {ip: "46.227.162.98", port: 51558}, {ip: "46.229.187.169", port: 53281}, {ip: "46.229.67.198", port: 47437}, {ip: "46.243.179.221", port: 41598}, {ip: "46.254.217.54", port: 53281}, {ip: "46.32.68.188", port: 39707}, {ip: "46.39.224.112", port: 36765}, {ip: "46.63.162.171", port: 8080}, {ip: "46.73.33.253", port: 8080}, {ip: "5.128.32.12", port: 51959}, {ip: "5.129.155.3", port: 51390}, {ip: "5.129.16.27", port: 48935}, {ip: "5.141.81.65", port: 61853}, {ip: "5.16.15.234", port: 8080}, {ip: "5.167.51.235", port: 8080}, {ip: "5.167.96.238", port: 3128}, {ip: "5.19.165.235", port: 30793}, {ip: "5.35.93.157", port: 31773}, {ip: "5.59.137.90", port: 8888}, {ip: "5.8.207.160", port: 57192}, {ip: "62.122.97.66", port: 59143}, {ip: "62.148.151.253", port: 53570}, {ip: "62.152.85.158", port: 31156}, {ip: "62.165.54.153", port: 55522}, {ip: "62.173.140.14", port: 8080}, {ip: "62.173.155.206", port: 41258}, {ip: "62.182.206.19", port: 37715}, {ip: "62.213.14.166", port: 8080}, {ip: "62.76.123.224", port: 8080}, {ip: "77.221.220.133", port: 44331}, {ip: "77.232.153.248", port: 60950}, {ip: "77.233.10.37", port: 54210}, {ip: "77.244.27.109", port: 47554}, {ip: "77.37.142.203", port: 53281}, {ip: "77.39.29.29", port: 49243}, {ip: "77.75.6.34", port: 8080}, {ip: "77.87.102.7", port: 42601}, {ip: "77.94.121.212", port: 36896}, {ip: "77.94.121.51", port: 45293}, {ip: "78.110.154.177", port: 59888}, {ip: "78.140.201.226", port: 8090}, {ip: "78.153.4.122", port: 9001}, {ip: "78.156.225.170", port: 41258}, {ip: "78.156.243.146", port: 59730}, {ip: "78.29.14.201", port: 39001}, {ip: "78.81.24.112", port: 8080}, {ip: "78.85.36.203", port: 8080}, {ip: "79.104.219.125", port: 3128}, {ip: "79.104.55.134", port: 8080}, {ip: "79.137.181.170", port: 8080}, {ip: "79.173.124.194", port: 47832}, {ip: "79.173.124.207", port: 53281}, {ip: "79.174.186.168", port: 45710}, {ip: "79.175.51.13", port: 54853}, {ip: "79.175.57.77", port: 55477}, {ip: "80.234.107.118", port: 56952}, {ip: "80.237.6.1", port: 34880}, {ip: "80.243.14.182", port: 49320}, {ip: "80.251.48.215", port: 45157}, {ip: "80.254.121.66", port: 41055}, {ip: "80.254.125.236", port: 80}, {ip: "80.72.121.185", port: 52379}, {ip: "80.89.133.210", port: 3128}, {ip: "80.91.17.113", port: 41258}, {ip: "81.162.61.166", port: 40392}, {ip: "81.163.57.121", port: 41258}, {ip: "81.163.57.46", port: 41258}, {ip: "81.163.62.136", port: 41258}, {ip: "81.23.112.98", port: 55269}, {ip: "81.23.118.106", port: 60427}, {ip: "81.23.177.245", port: 8080}, {ip: "81.24.126.166", port: 8080}, {ip: "81.30.216.147", port: 41258}, {ip: "81.95.131.10", port: 44292}, {ip: "82.114.125.22", port: 8080}, {ip: "82.151.208.20", port: 8080}, {ip: "83.221.216.110", port: 47326}, {ip: "83.246.139.24", port: 8080}, {ip: "83.97.108.8", port: 41258}, {ip: "84.22.154.76", port: 8080}, {ip: "84.52.110.36", port: 38674}, {ip: "84.52.74.194", port: 8080}, {ip: "84.52.77.227", port: 41806}, {ip: "84.52.79.166", port: 43548}, {ip: "84.52.84.157", port: 44331}, {ip: "84.52.88.125", port: 32666}, {ip: "85.113.48.148", port: 8080}, {ip: "85.113.49.220", port: 8080}, {ip: "85.12.193.210", port: 58470}, {ip: "85.15.179.5", port: 8080}, {ip: "85.173.244.102", port: 53281}, {ip: "85.174.227.52", port: 59280}, {ip: "85.192.184.133", port: 8080}, {ip: "85.192.184.133", port: 80}, {ip: "85.21.240.193", port: 55820}, {ip: "85.21.63.219", port: 53281}, {ip: "85.235.190.18", port: 42494}, {ip: "85.237.56.193", port: 8080}, {ip: "85.91.119.6", port: 8080}, {ip: "86.102.116.30", port: 8080}, {ip: "86.110.30.146", port: 38109}, {ip: "87.117.3.129", port: 3128}, {ip: "87.225.108.195", port: 8080}, {ip: "87.228.103.111", port: 8080}, {ip: "87.228.103.43", port: 8080}, {ip: "87.229.143.10", port: 48872}, {ip: "87.249.205.103", port: 8080}, {ip: "87.249.21.193", port: 43079}, {ip: "87.255.13.217", port: 8080}, {ip: "88.147.159.167", port: 53281}, {ip: "88.200.225.32", port: 38583}, {ip: "88.204.59.177", port: 32666}, {ip: "88.84.209.69", port: 30819}, {ip: "88.87.72.72", port: 8080}, {ip: "88.87.79.20", port: 8080}, {ip: "88.87.91.163", port: 48513}, {ip: "88.87.93.20", port: 33277}, {ip: "89.109.12.82", port: 47972}, {ip: "89.109.21.43", port: 9090}, {ip: "89.109.239.183", port: 41041}, {ip: "89.109.54.137", port: 36469}, {ip: "89.17.37.218", port: 52957}, {ip: "89.189.130.103", port: 32626}, {ip: "89.189.159.214", port: 42530}, {ip: "89.189.174.121", port: 52636}, {ip: "89.23.18.29", port: 53281}, {ip: "89.249.251.21", port: 3128}, {ip: "89.250.149.114", port: 60981}, {ip: "89.250.17.209", port: 8080}, {ip: "89.250.19.173", port: 8080}, {ip: "90.150.87.172", port: 81}, {ip: "90.154.125.173", port: 33078}, {ip: "90.188.38.81", port: 60585}, {ip: "90.189.151.183", port: 32601}, {ip: "91.103.208.114", port: 57063}, {ip: "91.122.100.222", port: 44331}, {ip: "91.122.207.229", port: 8080}, {ip: "91.144.139.93", port: 3128}, {ip: "91.144.142.19", port: 44617}, {ip: "91.146.16.54", port: 57902}, {ip: "91.190.116.194", port: 38783}, {ip: "91.190.80.100", port: 31659}, {ip: "91.190.85.97", port: 34286}, {ip: "91.203.36.188", port: 8080}, {ip: "91.205.131.102", port: 8080}, {ip: "91.205.146.25", port: 37501}, {ip: "91.210.94.212", port: 52635}, {ip: "91.213.23.110", port: 8080}, {ip: "91.215.22.51", port: 53305}, {ip: "91.217.42.3", port: 8080}, {ip: "91.217.42.4", port: 8080}, {ip: "91.220.135.146", port: 41258}, {ip: "91.222.167.213", port: 38057}, {ip: "91.226.140.71", port: 33199}, {ip: "91.235.7.216", port: 59067}, {ip: "92.124.195.22", port: 3128}, {ip: "92.126.193.180", port: 8080}, {ip: "92.241.110.223", port: 53281}, {ip: "92.252.240.1", port: 53281}, {ip: "92.255.164.187", port: 3128}, {ip: "92.255.195.57", port: 53281}, {ip: "92.255.229.146", port: 55785}, {ip: "92.255.5.2", port: 41012}, {ip: "92.38.32.36", port: 56113}, {ip: "92.39.138.98", port: 31150}, {ip: "92.51.16.155", port: 46202}, {ip: "92.55.59.63", port: 33030}, {ip: "93.170.112.200", port: 47995}, {ip: "93.183.86.185", port: 53281}, {ip: "93.188.45.157", port: 8080}, {ip: "93.81.246.5", port: 53281}, {ip: "93.91.112.247", port: 41258}, {ip: "94.127.217.66", port: 40115}, {ip: "94.154.85.214", port: 8080}, {ip: "94.180.106.94", port: 32767}, {ip: "94.180.249.187", port: 38051}, {ip: "94.230.243.6", port: 8080}, {ip: "94.232.57.231", port: 51064}, {ip: "94.24.244.170", port: 48936}, {ip: "94.242.55.108", port: 10010}, {ip: "94.242.55.108", port: 1448}, {ip: "94.242.57.136", port: 10010}, {ip: "94.242.57.136", port: 1448}, {ip: "94.242.58.108", port: 10010}, {ip: "94.242.58.108", port: 1448}, {ip: "94.242.58.14", port: 10010}, {ip: "94.242.58.14", port: 1448}, {ip: "94.242.58.142", port: 10010}, {ip: "94.242.58.142", port: 1448}, {ip: "94.242.59.245", port: 10010}, {ip: "94.242.59.245", port: 1448}, {ip: "94.247.241.70", port: 53640}, {ip: "94.247.62.165", port: 33176}, {ip: "94.253.13.228", port: 54935}, {ip: "94.253.14.187", port: 55045}, {ip: "94.28.94.154", port: 46966}, {ip: "94.73.217.125", port: 40858}, {ip: "95.140.19.9", port: 8080}, {ip: "95.140.20.94", port: 33994}, {ip: "95.154.137.66", port: 41258}, {ip: "95.154.159.119", port: 44242}, {ip: "95.154.82.254", port: 52484}, {ip: "95.161.157.227", port: 43170}, {ip: "95.161.182.146", port: 33877}, {ip: "95.161.189.26", port: 61522}, {ip: "95.165.163.146", port: 8888}, {ip: "95.165.172.90", port: 60496}, {ip: "95.165.182.18", port: 38950}, {ip: "95.165.203.222", port: 33805}, {ip: "95.165.244.122", port: 58162}, {ip: "95.167.123.54", port: 58664}, {ip: "95.167.241.242", port: 49636}, {ip: "95.171.1.92", port: 35956}, {ip: "95.172.52.230", port: 35989}, {ip: "95.181.35.30", port: 40804}, {ip: "95.181.56.178", port: 39144}, {ip: "95.181.75.228", port: 53281}, {ip: "95.188.74.194", port: 57122}, {ip: "95.189.112.214", port: 35508}, {ip: "95.31.10.247", port: 30711}, {ip: "95.31.197.77", port: 41651}, {ip: "95.31.2.199", port: 33632}, {ip: "95.71.125.50", port: 49882}, {ip: "95.73.62.13", port: 32185}, {ip: "95.79.36.55", port: 44861}, {ip: "95.79.55.196", port: 53281}, {ip: "95.79.99.148", port: 3128}, {ip: "95.80.65.39", port: 43555}, {ip: "95.80.93.44", port: 41258}, {ip: "95.80.98.41", port: 8080}, {ip: "95.83.156.250", port: 58438}, {ip: "95.84.128.25", port: 33765}, {ip: "95.84.154.73", port: 57423}], - "CA" => [{ip: "144.217.161.149", port: 8080}, {ip: "24.37.9.6", port: 54154}, {ip: "54.39.138.144", port: 3128}, {ip: "54.39.138.145", port: 3128}, {ip: "54.39.138.151", port: 3128}, {ip: "54.39.138.152", port: 3128}, {ip: "54.39.138.153", port: 3128}, {ip: "54.39.138.154", port: 3128}, {ip: "54.39.138.155", port: 3128}, {ip: "54.39.138.156", port: 3128}, {ip: "54.39.138.157", port: 3128}, {ip: "54.39.53.104", port: 3128}, {ip: "66.70.167.113", port: 3128}, {ip: "66.70.167.116", port: 3128}, {ip: "66.70.167.117", port: 3128}, {ip: "66.70.167.119", port: 3128}, {ip: "66.70.167.120", port: 3128}, {ip: "66.70.167.125", port: 3128}, {ip: "66.70.188.148", port: 3128}, {ip: "70.35.213.229", port: 36127}, {ip: "70.65.233.174", port: 8080}, {ip: "72.139.24.66", port: 38861}, {ip: "74.15.191.160", port: 41564}], - "JP" => [{ip: "47.91.20.67", port: 8080}, {ip: "61.118.35.94", port: 55725}], - "IT" => [{ip: "109.70.201.97", port: 53517}, {ip: "176.31.82.212", port: 8080}, {ip: "185.132.228.118", port: 55583}, {ip: "185.49.58.88", port: 56006}, {ip: "185.94.89.179", port: 41258}, {ip: "213.203.134.10", port: 41258}, {ip: "217.61.172.12", port: 41369}, {ip: "46.232.143.126", port: 41258}, {ip: "46.232.143.253", port: 41258}, {ip: "93.67.154.125", port: 8080}, {ip: "93.67.154.125", port: 80}, {ip: "95.169.95.242", port: 53803}], - "TH" => [{ip: "1.10.184.166", port: 57330}, {ip: "1.10.186.100", port: 55011}, {ip: "1.10.186.209", port: 32431}, {ip: "1.10.186.245", port: 34360}, {ip: "1.10.186.93", port: 53711}, {ip: "1.10.187.118", port: 62000}, {ip: "1.10.187.34", port: 51635}, {ip: "1.10.187.43", port: 38715}, {ip: "1.10.188.181", port: 51093}, {ip: "1.10.188.83", port: 31940}, {ip: "1.10.188.95", port: 30593}, {ip: "1.10.189.58", port: 48564}, {ip: "1.179.157.237", port: 46178}, {ip: "1.179.164.213", port: 8080}, {ip: "1.179.198.37", port: 8080}, {ip: "1.20.100.99", port: 53794}, {ip: "1.20.101.221", port: 55707}, {ip: "1.20.101.254", port: 35394}, {ip: "1.20.101.80", port: 36234}, {ip: "1.20.102.133", port: 40296}, {ip: "1.20.103.13", port: 40544}, {ip: "1.20.103.56", port: 55422}, {ip: "1.20.96.234", port: 53142}, {ip: "1.20.97.54", port: 60122}, {ip: "1.20.99.63", port: 32123}, {ip: "101.108.92.20", port: 8080}, {ip: "101.109.143.71", port: 36127}, {ip: "101.51.141.110", port: 42860}, {ip: "101.51.141.60", port: 60417}, {ip: "103.246.17.237", port: 3128}, {ip: "110.164.73.131", port: 8080}, {ip: "110.164.87.80", port: 35844}, {ip: "110.77.134.106", port: 8080}, {ip: "113.53.29.92", port: 47297}, {ip: "113.53.83.192", port: 32780}, {ip: "113.53.83.195", port: 35686}, {ip: "113.53.91.214", port: 8080}, {ip: "115.87.27.0", port: 53276}, {ip: "118.172.211.3", port: 58535}, {ip: "118.172.211.40", port: 30430}, {ip: "118.174.196.174", port: 23500}, {ip: "118.174.196.203", port: 23500}, {ip: "118.174.220.107", port: 41222}, {ip: "118.174.220.110", port: 39025}, {ip: "118.174.220.115", port: 41011}, {ip: "118.174.220.118", port: 59556}, {ip: "118.174.220.136", port: 55041}, {ip: "118.174.220.163", port: 31561}, {ip: "118.174.220.168", port: 47455}, {ip: "118.174.220.231", port: 40924}, {ip: "118.174.220.238", port: 46326}, {ip: "118.174.234.13", port: 53084}, {ip: "118.174.234.26", port: 41926}, {ip: "118.174.234.32", port: 57403}, {ip: "118.174.234.59", port: 59149}, {ip: "118.174.234.68", port: 42626}, {ip: "118.174.234.83", port: 38006}, {ip: "118.175.207.104", port: 38959}, {ip: "118.175.244.111", port: 8080}, {ip: "118.175.93.207", port: 50738}, {ip: "122.154.38.53", port: 8080}, {ip: "122.154.59.6", port: 8080}, {ip: "122.154.72.102", port: 8080}, {ip: "122.155.222.98", port: 3128}, {ip: "124.121.22.121", port: 61699}, {ip: "125.24.156.16", port: 44321}, {ip: "125.25.165.105", port: 33850}, {ip: "125.25.165.111", port: 40808}, {ip: "125.25.165.42", port: 47221}, {ip: "125.25.201.14", port: 30100}, {ip: "125.26.99.135", port: 55637}, {ip: "125.26.99.141", port: 38537}, {ip: "125.26.99.148", port: 31818}, {ip: "134.236.247.137", port: 8080}, {ip: "159.192.98.224", port: 3128}, {ip: "171.100.2.154", port: 8080}, {ip: "171.100.9.126", port: 49163}, {ip: "180.180.156.116", port: 48431}, {ip: "180.180.156.46", port: 48507}, {ip: "180.180.156.87", port: 36628}, {ip: "180.180.218.204", port: 51565}, {ip: "180.180.8.34", port: 8080}, {ip: "182.52.238.125", port: 58861}, {ip: "182.52.74.73", port: 36286}, {ip: "182.52.74.76", port: 34084}, {ip: "182.52.74.77", port: 34825}, {ip: "182.52.74.78", port: 48708}, {ip: "182.52.90.45", port: 53799}, {ip: "182.53.206.155", port: 34307}, {ip: "182.53.206.43", port: 45330}, {ip: "182.53.206.49", port: 54228}, {ip: "183.88.212.141", port: 8080}, {ip: "183.88.212.184", port: 8080}, {ip: "183.88.213.85", port: 8080}, {ip: "183.88.214.47", port: 8080}, {ip: "184.82.128.211", port: 8080}, {ip: "202.183.201.13", port: 8081}, {ip: "202.29.20.151", port: 43083}, {ip: "203.150.172.151", port: 8080}, {ip: "27.131.157.94", port: 8080}, {ip: "27.145.100.22", port: 8080}, {ip: "27.145.100.243", port: 8080}, {ip: "49.231.196.114", port: 53281}, {ip: "58.97.72.83", port: 8080}, {ip: "61.19.145.66", port: 8080}], - "ES" => [{ip: "185.198.184.14", port: 48122}, {ip: "185.26.226.241", port: 36012}, {ip: "194.224.188.82", port: 3128}, {ip: "195.235.68.61", port: 3128}, {ip: "195.53.237.122", port: 3128}, {ip: "195.53.86.82", port: 3128}, {ip: "213.96.245.47", port: 8080}, {ip: "217.125.71.214", port: 33950}, {ip: "62.14.178.72", port: 53281}, {ip: "80.35.254.42", port: 53281}, {ip: "81.33.4.214", port: 61711}, {ip: "83.175.238.170", port: 53281}, {ip: "85.217.137.77", port: 3128}, {ip: "90.170.205.178", port: 33680}, {ip: "93.156.177.91", port: 53281}, {ip: "95.60.152.139", port: 37995}], - "AE" => [{ip: "178.32.5.90", port: 36159}], - "KR" => [{ip: "112.217.219.179", port: 3128}, {ip: "114.141.229.2", port: 58115}, {ip: "121.139.218.165", port: 31409}, {ip: "122.49.112.2", port: 38592}, {ip: "61.42.18.132", port: 53281}], - "BR" => [{ip: "128.201.97.157", port: 53281}, {ip: "128.201.97.158", port: 53281}, {ip: "131.0.246.157", port: 35252}, {ip: "131.161.26.90", port: 8080}, {ip: "131.72.143.100", port: 41396}, {ip: "138.0.24.66", port: 53281}, {ip: "138.121.130.50", port: 50600}, {ip: "138.121.155.127", port: 61932}, {ip: "138.121.32.133", port: 23492}, {ip: "138.185.176.63", port: 53281}, {ip: "138.204.233.190", port: 53281}, {ip: "138.204.233.242", port: 53281}, {ip: "138.219.71.74", port: 52688}, {ip: "138.36.107.24", port: 41184}, {ip: "138.94.115.166", port: 8080}, {ip: "143.0.188.161", port: 53281}, {ip: "143.202.218.135", port: 8080}, {ip: "143.208.2.42", port: 53281}, {ip: "143.208.79.223", port: 8080}, {ip: "143.255.52.102", port: 40687}, {ip: "143.255.52.116", port: 57856}, {ip: "143.255.52.117", port: 37279}, {ip: "144.217.22.128", port: 8080}, {ip: "168.0.8.225", port: 8080}, {ip: "168.0.8.55", port: 8080}, {ip: "168.121.139.54", port: 40056}, {ip: "168.181.168.23", port: 53281}, {ip: "168.181.170.198", port: 31935}, {ip: "168.232.198.25", port: 32009}, {ip: "168.232.198.35", port: 42267}, {ip: "168.232.207.145", port: 46342}, {ip: "170.0.104.107", port: 60337}, {ip: "170.0.112.2", port: 50359}, {ip: "170.0.112.229", port: 50359}, {ip: "170.238.118.107", port: 34314}, {ip: "170.239.144.9", port: 3128}, {ip: "170.247.29.138", port: 8080}, {ip: "170.81.237.36", port: 37124}, {ip: "170.84.51.74", port: 53281}, {ip: "170.84.60.222", port: 42981}, {ip: "177.10.202.67", port: 8080}, {ip: "177.101.60.86", port: 80}, {ip: "177.103.231.211", port: 55091}, {ip: "177.12.80.50", port: 50556}, {ip: "177.131.13.9", port: 20183}, {ip: "177.135.178.115", port: 42510}, {ip: "177.135.248.75", port: 20183}, {ip: "177.184.206.238", port: 39508}, {ip: "177.185.148.46", port: 58623}, {ip: "177.200.83.238", port: 8080}, {ip: "177.21.24.146", port: 666}, {ip: "177.220.188.120", port: 47556}, {ip: "177.220.188.213", port: 8080}, {ip: "177.222.229.243", port: 23500}, {ip: "177.234.161.42", port: 8080}, {ip: "177.36.11.241", port: 3128}, {ip: "177.36.12.193", port: 23500}, {ip: "177.37.199.175", port: 49608}, {ip: "177.39.187.70", port: 37315}, {ip: "177.44.175.199", port: 8080}, {ip: "177.46.148.126", port: 3128}, {ip: "177.46.148.142", port: 3128}, {ip: "177.47.194.98", port: 21231}, {ip: "177.5.98.58", port: 20183}, {ip: "177.52.55.19", port: 60901}, {ip: "177.54.200.66", port: 57526}, {ip: "177.55.255.74", port: 37147}, {ip: "177.67.217.94", port: 53281}, {ip: "177.73.248.6", port: 54381}, {ip: "177.73.4.234", port: 23500}, {ip: "177.75.143.211", port: 35955}, {ip: "177.75.161.206", port: 3128}, {ip: "177.75.86.49", port: 20183}, {ip: "177.8.216.106", port: 8080}, {ip: "177.8.216.114", port: 8080}, {ip: "177.8.37.247", port: 56052}, {ip: "177.84.216.17", port: 50569}, {ip: "177.85.200.254", port: 53095}, {ip: "177.87.169.1", port: 53281}, {ip: "179.107.97.178", port: 3128}, {ip: "179.109.144.25", port: 8080}, {ip: "179.109.193.137", port: 53281}, {ip: "179.189.125.206", port: 8080}, {ip: "179.97.30.46", port: 53100}, {ip: "186.192.195.220", port: 38983}, {ip: "186.193.11.226", port: 48999}, {ip: "186.193.26.106", port: 3128}, {ip: "186.208.220.248", port: 3128}, {ip: "186.209.243.142", port: 3128}, {ip: "186.209.243.233", port: 3128}, {ip: "186.211.106.227", port: 34334}, {ip: "186.211.160.178", port: 36756}, {ip: "186.215.133.170", port: 20183}, {ip: "186.216.81.21", port: 31773}, {ip: "186.219.214.13", port: 32708}, {ip: "186.224.94.6", port: 48957}, {ip: "186.225.97.246", port: 43082}, {ip: "186.226.171.163", port: 48698}, {ip: "186.226.179.2", port: 56089}, {ip: "186.226.234.67", port: 33834}, {ip: "186.228.147.58", port: 20183}, {ip: "186.233.97.163", port: 8888}, {ip: "186.248.170.82", port: 53281}, {ip: "186.249.213.101", port: 53482}, {ip: "186.249.213.65", port: 52018}, {ip: "186.250.213.225", port: 60774}, {ip: "186.250.96.70", port: 8080}, {ip: "186.250.96.77", port: 8080}, {ip: "187.1.43.246", port: 53396}, {ip: "187.108.36.250", port: 20183}, {ip: "187.108.38.10", port: 20183}, {ip: "187.109.36.251", port: 20183}, {ip: "187.109.40.9", port: 20183}, {ip: "187.109.56.101", port: 20183}, {ip: "187.111.90.89", port: 53281}, {ip: "187.115.10.50", port: 20183}, {ip: "187.19.62.7", port: 59010}, {ip: "187.33.79.61", port: 33469}, {ip: "187.35.158.150", port: 38872}, {ip: "187.44.1.167", port: 8080}, {ip: "187.45.127.87", port: 20183}, {ip: "187.45.156.109", port: 8080}, {ip: "187.5.218.215", port: 20183}, {ip: "187.58.65.225", port: 3128}, {ip: "187.63.111.37", port: 3128}, {ip: "187.72.166.10", port: 8080}, {ip: "187.73.68.14", port: 53281}, {ip: "187.84.177.6", port: 45903}, {ip: "187.84.191.170", port: 43936}, {ip: "187.87.204.210", port: 45597}, {ip: "187.87.39.247", port: 31793}, {ip: "189.1.16.162", port: 23500}, {ip: "189.113.124.162", port: 8080}, {ip: "189.124.195.185", port: 37318}, {ip: "189.3.196.18", port: 61595}, {ip: "189.37.33.59", port: 35532}, {ip: "189.7.49.66", port: 42700}, {ip: "189.90.194.35", port: 30843}, {ip: "189.90.248.75", port: 8080}, {ip: "189.91.231.43", port: 3128}, {ip: "191.239.243.156", port: 3128}, {ip: "191.240.154.246", port: 23500}, {ip: "191.240.156.154", port: 36127}, {ip: "191.240.99.142", port: 9090}, {ip: "191.241.226.230", port: 53281}, {ip: "191.241.228.74", port: 20183}, {ip: "191.241.228.78", port: 20183}, {ip: "191.241.33.238", port: 39188}, {ip: "191.241.36.170", port: 8080}, {ip: "191.241.36.218", port: 3128}, {ip: "191.242.182.132", port: 8081}, {ip: "191.243.221.130", port: 3128}, {ip: "191.255.207.231", port: 20183}, {ip: "191.36.192.196", port: 3128}, {ip: "191.36.244.230", port: 51377}, {ip: "191.5.0.79", port: 53281}, {ip: "191.6.228.6", port: 53281}, {ip: "191.7.193.18", port: 38133}, {ip: "191.7.20.134", port: 3128}, {ip: "192.140.91.173", port: 20183}, {ip: "200.150.86.138", port: 44677}, {ip: "200.155.36.185", port: 3128}, {ip: "200.155.36.188", port: 3128}, {ip: "200.155.39.41", port: 3128}, {ip: "200.174.158.26", port: 34112}, {ip: "200.187.177.105", port: 20183}, {ip: "200.187.87.138", port: 20183}, {ip: "200.192.252.201", port: 8080}, {ip: "200.192.255.102", port: 8080}, {ip: "200.203.144.2", port: 50262}, {ip: "200.229.238.42", port: 20183}, {ip: "200.233.134.85", port: 43172}, {ip: "200.233.136.177", port: 20183}, {ip: "200.241.44.3", port: 20183}, {ip: "200.255.122.170", port: 8080}, {ip: "200.255.122.174", port: 8080}, {ip: "201.12.21.57", port: 8080}, {ip: "201.131.224.21", port: 56200}, {ip: "201.182.223.16", port: 37492}, {ip: "201.20.89.126", port: 8080}, {ip: "201.22.95.10", port: 8080}, {ip: "201.57.167.34", port: 8080}, {ip: "201.59.200.246", port: 80}, {ip: "201.6.167.178", port: 3128}, {ip: "201.90.36.194", port: 3128}, {ip: "45.226.20.6", port: 8080}, {ip: "45.234.139.129", port: 20183}, {ip: "45.234.200.18", port: 53281}, {ip: "45.235.87.4", port: 51996}, {ip: "45.6.136.38", port: 53281}, {ip: "45.6.80.131", port: 52080}, {ip: "45.6.93.10", port: 8080}, {ip: "45.71.108.162", port: 53281}], - "PK" => [{ip: "103.18.243.154", port: 8080}, {ip: "110.36.218.126", port: 36651}, {ip: "110.36.234.210", port: 8080}, {ip: "110.39.162.74", port: 53281}, {ip: "110.39.174.58", port: 8080}, {ip: "111.68.108.34", port: 8080}, {ip: "125.209.116.182", port: 31653}, {ip: "125.209.78.21", port: 8080}, {ip: "125.209.82.78", port: 35087}, {ip: "180.92.156.150", port: 8080}, {ip: "202.142.158.114", port: 8080}, {ip: "202.147.173.10", port: 8080}, {ip: "202.147.173.10", port: 80}, {ip: "202.69.38.82", port: 8080}, {ip: "203.128.16.126", port: 59538}, {ip: "203.128.16.154", port: 33002}, {ip: "27.255.4.170", port: 8080}], - "ID" => [{ip: "101.128.68.113", port: 8080}, {ip: "101.255.116.113", port: 53281}, {ip: "101.255.120.170", port: 6969}, {ip: "101.255.121.74", port: 8080}, {ip: "101.255.124.242", port: 8080}, {ip: "101.255.124.242", port: 80}, {ip: "101.255.56.138", port: 53560}, {ip: "103.10.171.132", port: 41043}, {ip: "103.10.81.172", port: 80}, {ip: "103.108.158.3", port: 48196}, {ip: "103.111.219.159", port: 53281}, {ip: "103.111.54.26", port: 49781}, {ip: "103.111.54.74", port: 8080}, {ip: "103.19.110.177", port: 8080}, {ip: "103.2.146.66", port: 49089}, {ip: "103.206.168.177", port: 53281}, {ip: "103.206.253.58", port: 49573}, {ip: "103.21.92.254", port: 33929}, {ip: "103.226.49.83", port: 23500}, {ip: "103.227.147.142", port: 37581}, {ip: "103.23.101.58", port: 8080}, {ip: "103.24.107.2", port: 8181}, {ip: "103.245.19.222", port: 53281}, {ip: "103.247.122.38", port: 8080}, {ip: "103.247.218.166", port: 3128}, {ip: "103.248.219.26", port: 53634}, {ip: "103.253.2.165", port: 33543}, {ip: "103.253.2.168", port: 51229}, {ip: "103.253.2.174", port: 30827}, {ip: "103.28.114.134", port: 8080}, {ip: "103.28.220.73", port: 53281}, {ip: "103.30.246.47", port: 3128}, {ip: "103.31.45.169", port: 57655}, {ip: "103.41.122.14", port: 53281}, {ip: "103.75.101.97", port: 8080}, {ip: "103.76.17.151", port: 23500}, {ip: "103.76.50.181", port: 8080}, {ip: "103.76.50.181", port: 80}, {ip: "103.76.50.182", port: 8080}, {ip: "103.78.74.170", port: 3128}, {ip: "103.78.80.194", port: 33442}, {ip: "103.8.122.5", port: 53297}, {ip: "103.80.236.107", port: 53281}, {ip: "103.80.238.203", port: 53281}, {ip: "103.86.140.74", port: 59538}, {ip: "103.94.122.254", port: 8080}, {ip: "103.94.125.244", port: 41508}, {ip: "103.94.169.19", port: 8080}, {ip: "103.94.7.254", port: 53281}, {ip: "106.0.51.50", port: 17385}, {ip: "110.93.13.202", port: 34881}, {ip: "112.78.37.6", port: 54791}, {ip: "114.199.110.58", port: 55898}, {ip: "114.199.112.170", port: 23500}, {ip: "114.199.123.194", port: 8080}, {ip: "114.57.33.162", port: 46935}, {ip: "114.57.33.214", port: 8080}, {ip: "114.6.197.254", port: 8080}, {ip: "114.7.15.146", port: 8080}, {ip: "114.7.162.254", port: 53281}, {ip: "115.124.75.226", port: 53990}, {ip: "115.124.75.228", port: 3128}, {ip: "117.102.78.42", port: 8080}, {ip: "117.102.93.251", port: 8080}, {ip: "117.102.94.186", port: 8080}, {ip: "117.102.94.186", port: 80}, {ip: "117.103.2.249", port: 58276}, {ip: "117.54.13.174", port: 34190}, {ip: "117.74.124.129", port: 8088}, {ip: "118.97.100.83", port: 35220}, {ip: "118.97.191.162", port: 80}, {ip: "118.97.191.203", port: 8080}, {ip: "118.97.36.18", port: 8080}, {ip: "118.97.73.85", port: 53281}, {ip: "118.99.105.226", port: 8080}, {ip: "119.252.168.53", port: 53281}, {ip: "122.248.45.35", port: 53281}, {ip: "122.50.6.186", port: 8080}, {ip: "122.50.6.186", port: 80}, {ip: "123.231.226.114", port: 47562}, {ip: "123.255.202.83", port: 32523}, {ip: "124.158.164.195", port: 8080}, {ip: "124.81.99.30", port: 3128}, {ip: "137.59.162.10", port: 3128}, {ip: "139.0.29.20", port: 59532}, {ip: "139.255.123.194", port: 4550}, {ip: "139.255.16.171", port: 31773}, {ip: "139.255.17.2", port: 47421}, {ip: "139.255.19.162", port: 42371}, {ip: "139.255.7.81", port: 53281}, {ip: "139.255.91.115", port: 8080}, {ip: "139.255.92.26", port: 53281}, {ip: "158.140.181.140", port: 54041}, {ip: "160.202.40.20", port: 55655}, {ip: "175.103.42.147", port: 8080}, {ip: "180.178.98.198", port: 8080}, {ip: "180.250.101.146", port: 8080}, {ip: "182.23.107.212", port: 3128}, {ip: "182.23.2.101", port: 49833}, {ip: "182.23.7.226", port: 8080}, {ip: "182.253.209.203", port: 3128}, {ip: "183.91.66.210", port: 80}, {ip: "202.137.10.179", port: 57338}, {ip: "202.137.25.53", port: 3128}, {ip: "202.137.25.8", port: 8080}, {ip: "202.138.242.76", port: 4550}, {ip: "202.138.249.202", port: 43108}, {ip: "202.148.2.254", port: 8000}, {ip: "202.162.201.94", port: 53281}, {ip: "202.165.47.26", port: 8080}, {ip: "202.43.167.130", port: 8080}, {ip: "202.51.126.10", port: 53281}, {ip: "202.59.171.164", port: 58567}, {ip: "202.93.128.98", port: 3128}, {ip: "203.142.72.114", port: 808}, {ip: "203.153.117.65", port: 54144}, {ip: "203.189.89.1", port: 53281}, {ip: "203.77.239.18", port: 37002}, {ip: "203.99.123.25", port: 61502}, {ip: "220.247.168.163", port: 53281}, {ip: "220.247.173.154", port: 53281}, {ip: "220.247.174.206", port: 53445}, {ip: "222.124.131.211", port: 47343}, {ip: "222.124.173.146", port: 53281}, {ip: "222.124.2.131", port: 8080}, {ip: "222.124.2.186", port: 8080}, {ip: "222.124.215.187", port: 38913}, {ip: "222.124.221.179", port: 53281}, {ip: "223.25.101.242", port: 59504}, {ip: "223.25.97.62", port: 8080}, {ip: "223.25.99.38", port: 80}, {ip: "27.111.44.202", port: 80}, {ip: "27.111.47.3", port: 51144}, {ip: "36.37.124.234", port: 36179}, {ip: "36.37.124.235", port: 36179}, {ip: "36.37.81.135", port: 8080}, {ip: "36.37.89.98", port: 32323}, {ip: "36.66.217.179", port: 8080}, {ip: "36.66.98.6", port: 53281}, {ip: "36.67.143.183", port: 48746}, {ip: "36.67.206.187", port: 8080}, {ip: "36.67.32.87", port: 8080}, {ip: "36.67.93.220", port: 3128}, {ip: "36.67.93.220", port: 80}, {ip: "36.89.10.51", port: 34115}, {ip: "36.89.119.149", port: 8080}, {ip: "36.89.157.23", port: 37728}, {ip: "36.89.181.155", port: 60165}, {ip: "36.89.188.11", port: 39507}, {ip: "36.89.194.113", port: 37811}, {ip: "36.89.226.254", port: 8081}, {ip: "36.89.232.138", port: 23500}, {ip: "36.89.39.10", port: 3128}, {ip: "36.89.65.253", port: 60997}, {ip: "43.243.141.114", port: 8080}, {ip: "43.245.184.202", port: 41102}, {ip: "43.245.184.238", port: 80}, {ip: "66.96.233.225", port: 35053}, {ip: "66.96.237.253", port: 8080}], - "BD" => [{ip: "103.103.88.91", port: 8080}, {ip: "103.106.119.154", port: 8080}, {ip: "103.106.236.1", port: 8080}, {ip: "103.106.236.41", port: 8080}, {ip: "103.108.144.139", port: 53281}, {ip: "103.109.57.218", port: 8080}, {ip: "103.109.58.242", port: 8080}, {ip: "103.112.129.106", port: 31094}, {ip: "103.112.129.82", port: 53281}, {ip: "103.114.10.177", port: 8080}, {ip: "103.114.10.250", port: 8080}, {ip: "103.15.245.26", port: 8080}, {ip: "103.195.204.73", port: 21776}, {ip: "103.197.49.106", port: 49688}, {ip: "103.198.168.29", port: 21776}, {ip: "103.214.200.6", port: 59008}, {ip: "103.218.25.161", port: 8080}, {ip: "103.218.25.41", port: 8080}, {ip: "103.218.26.204", port: 8080}, {ip: "103.218.27.221", port: 8080}, {ip: "103.231.229.90", port: 53281}, {ip: "103.239.252.233", port: 8080}, {ip: "103.239.252.50", port: 8080}, {ip: "103.239.253.193", port: 8080}, {ip: "103.250.68.193", port: 51370}, {ip: "103.5.232.146", port: 8080}, {ip: "103.73.224.53", port: 23500}, {ip: "103.9.134.73", port: 65301}, {ip: "113.11.47.242", port: 40071}, {ip: "113.11.5.67", port: 40071}, {ip: "114.31.5.34", port: 52606}, {ip: "115.127.51.226", port: 42764}, {ip: "115.127.64.62", port: 39611}, {ip: "115.127.91.106", port: 8080}, {ip: "119.40.85.198", port: 36899}, {ip: "123.200.29.110", port: 23500}, {ip: "123.49.51.42", port: 55124}, {ip: "163.47.36.90", port: 3128}, {ip: "180.211.134.158", port: 23500}, {ip: "180.211.193.74", port: 40536}, {ip: "180.92.238.226", port: 53451}, {ip: "182.160.104.213", port: 8080}, {ip: "202.191.126.58", port: 23500}, {ip: "202.4.126.170", port: 8080}, {ip: "202.5.37.241", port: 33623}, {ip: "202.5.57.5", port: 61729}, {ip: "202.79.17.65", port: 60122}, {ip: "203.188.248.52", port: 23500}, {ip: "27.147.146.78", port: 52220}, {ip: "27.147.164.10", port: 52344}, {ip: "27.147.212.38", port: 53281}, {ip: "27.147.217.154", port: 43252}, {ip: "27.147.219.102", port: 49464}, {ip: "43.239.74.137", port: 8080}, {ip: "43.240.103.252", port: 8080}, {ip: "45.125.223.57", port: 8080}, {ip: "45.125.223.81", port: 8080}, {ip: "45.251.228.122", port: 41418}, {ip: "45.64.132.137", port: 8080}, {ip: "45.64.132.137", port: 80}, {ip: "61.247.186.137", port: 8080}], - "MX" => [{ip: "148.217.94.54", port: 3128}, {ip: "177.244.28.77", port: 53281}, {ip: "187.141.73.147", port: 53281}, {ip: "187.185.15.35", port: 53281}, {ip: "187.188.46.172", port: 53455}, {ip: "187.216.83.185", port: 8080}, {ip: "187.216.90.46", port: 53281}, {ip: "187.243.253.182", port: 33796}, {ip: "189.195.132.86", port: 43286}, {ip: "189.204.158.161", port: 8080}, {ip: "200.79.180.115", port: 8080}, {ip: "201.140.113.90", port: 37193}, {ip: "201.144.14.229", port: 53281}, {ip: "201.163.73.93", port: 53281}], - "PH" => [{ip: "103.86.187.242", port: 23500}, {ip: "122.54.101.69", port: 8080}, {ip: "122.54.65.150", port: 8080}, {ip: "125.5.20.134", port: 53281}, {ip: "146.88.77.51", port: 8080}, {ip: "182.18.200.92", port: 8080}, {ip: "219.90.87.91", port: 53281}, {ip: "58.69.12.210", port: 8080}], - "EG" => [{ip: "41.65.0.167", port: 8080}], - "VN" => [{ip: "1.55.240.156", port: 53281}, {ip: "101.99.23.136", port: 3128}, {ip: "103.15.51.160", port: 8080}, {ip: "113.161.128.169", port: 60427}, {ip: "113.161.161.143", port: 57967}, {ip: "113.161.173.10", port: 3128}, {ip: "113.161.35.108", port: 30028}, {ip: "113.164.79.177", port: 46281}, {ip: "113.190.235.50", port: 34619}, {ip: "115.78.160.247", port: 8080}, {ip: "117.2.155.29", port: 47228}, {ip: "117.2.17.26", port: 53281}, {ip: "117.2.22.41", port: 41973}, {ip: "117.4.145.16", port: 51487}, {ip: "118.69.219.185", port: 55184}, {ip: "118.69.61.212", port: 53281}, {ip: "118.70.116.227", port: 61651}, {ip: "118.70.219.124", port: 53281}, {ip: "221.121.12.238", port: 36077}, {ip: "27.2.7.59", port: 52148}], - "CD" => [{ip: "41.79.233.45", port: 8080}], - "TR" => [{ip: "151.80.65.175", port: 3128}, {ip: "176.235.186.242", port: 37043}, {ip: "178.250.92.18", port: 8080}, {ip: "185.203.170.92", port: 8080}, {ip: "185.203.170.94", port: 8080}, {ip: "185.203.170.95", port: 8080}, {ip: "185.51.36.152", port: 41258}, {ip: "195.137.223.50", port: 41336}, {ip: "195.155.98.70", port: 52598}, {ip: "212.156.146.22", port: 40080}, {ip: "213.14.31.122", port: 44621}, {ip: "31.145.137.139", port: 31871}, {ip: "31.145.138.129", port: 31871}, {ip: "31.145.138.146", port: 34159}, {ip: "31.145.187.172", port: 30636}, {ip: "78.188.4.124", port: 34514}, {ip: "88.248.23.216", port: 36426}, {ip: "93.182.72.36", port: 8080}, {ip: "95.0.194.241", port: 9090}], -} diff --git a/src/invidious/helpers/youtube_api.cr b/src/invidious/helpers/youtube_api.cr deleted file mode 100644 index b3815f6a..00000000 --- a/src/invidious/helpers/youtube_api.cr +++ /dev/null @@ -1,447 +0,0 @@ -# -# This file contains youtube API wrappers -# - -module YoutubeAPI - extend self - - # Enumerate used to select one of the clients supported by the API - enum ClientType - Web - WebEmbeddedPlayer - WebMobile - WebScreenEmbed - Android - AndroidEmbeddedPlayer - AndroidScreenEmbed - end - - # List of hard-coded values used by the different clients - HARDCODED_CLIENTS = { - ClientType::Web => { - name: "WEB", - version: "2.20210721.00.00", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", - screen: "WATCH_FULL_SCREEN", - }, - ClientType::WebEmbeddedPlayer => { - name: "WEB_EMBEDDED_PLAYER", # 56 - version: "1.20210721.1.0", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", - screen: "EMBED", - }, - ClientType::WebMobile => { - name: "MWEB", - version: "2.20210726.08.00", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", - screen: "", # None - }, - ClientType::WebScreenEmbed => { - name: "WEB", - version: "2.20210721.00.00", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", - screen: "EMBED", - }, - ClientType::Android => { - name: "ANDROID", - version: "16.20", - api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", - screen: "", # ?? - }, - ClientType::AndroidEmbeddedPlayer => { - name: "ANDROID_EMBEDDED_PLAYER", # 55 - version: "16.20", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", - screen: "", # None? - }, - ClientType::AndroidScreenEmbed => { - name: "ANDROID", # 3 - version: "16.20", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", - screen: "EMBED", - }, - } - - #################################################################### - # struct ClientConfig - # - # Data structure used to pass a client configuration to the different - # API endpoints handlers. - # - # Use case examples: - # - # ``` - # # Get Norwegian search results - # conf_1 = ClientConfig.new(region: "NO") - # YoutubeAPI::search("Kollektivet", params: "", client_config: conf_1) - # - # # Use the Android client to request video streams URLs - # conf_2 = ClientConfig.new(client_type: ClientType::Android) - # YoutubeAPI::player(video_id: "dQw4w9WgXcQ", client_config: conf_2) - # - # # Proxy request through russian proxies - # conf_3 = ClientConfig.new(proxy_region: "RU") - # YoutubeAPI::next({video_id: "dQw4w9WgXcQ"}, client_config: conf_3) - # ``` - # - struct ClientConfig - # Type of client to emulate. - # See `enum ClientType` and `HARDCODED_CLIENTS`. - property client_type : ClientType - - # Region to provide to youtube, e.g to alter search results - # (this is passed as the `gl` parmeter). - property region : String | Nil - - # ISO code of country where the proxy is located. - # Used in case of geo-restricted videos. - property proxy_region : String | Nil - - # Initialization function - def initialize( - *, - @client_type = ClientType::Web, - @region = "US", - @proxy_region = nil - ) - end - - # Getter functions that provides easy access to hardcoded clients - # parameters (name/version strings and related API key) - def name : String - HARDCODED_CLIENTS[@client_type][:name] - end - - # :ditto: - def version : String - HARDCODED_CLIENTS[@client_type][:version] - end - - # :ditto: - def api_key : String - HARDCODED_CLIENTS[@client_type][:api_key] - end - - # :ditto: - def screen : String - HARDCODED_CLIENTS[@client_type][:screen] - end - - # Convert to string, for logging purposes - def to_s - return { - client_type: self.name, - region: @region, - proxy_region: @proxy_region, - }.to_s - end - end - - # Default client config, used if nothing is passed - DEFAULT_CLIENT_CONFIG = ClientConfig.new - - #################################################################### - # make_context(client_config) - # - # Return, as a Hash, the "context" data required to request the - # youtube API endpoints. - # - private def make_context(client_config : ClientConfig | Nil) : Hash - # Use the default client config if nil is passed - client_config ||= DEFAULT_CLIENT_CONFIG - - client_context = { - "client" => { - "hl" => "en", - "gl" => client_config.region || "US", # Can't be empty! - "clientName" => client_config.name, - "clientVersion" => client_config.version, - }, - } - - # Add some more context if it exists in the client definitions - if !client_config.screen.empty? - client_context["client"]["clientScreen"] = client_config.screen - end - - if client_config.screen == "EMBED" - client_context["thirdParty"] = { - "embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ", - } - end - - return client_context - end - - #################################################################### - # browse(continuation, client_config?) - # browse(browse_id, params, client_config?) - # - # Requests the youtubei/v1/browse endpoint with the required headers - # and POST data in order to get a JSON reply in english that can - # be easily parsed. - # - # Both forms can take an optional ClientConfig parameter (see - # `struct ClientConfig` above for more details). - # - # The requested data can either be: - # - # - A continuation token (ctoken). Depending on this token's - # contents, the returned data can be playlist videos, channel - # community tab content, channel info, ... - # - # - A playlist ID (parameters MUST be an empty string) - # - def browse(continuation : String, client_config : ClientConfig | Nil = nil) - # JSON Request data, required by the API - data = { - "context" => self.make_context(client_config), - "continuation" => continuation, - } - - return self._post_json("/youtubei/v1/browse", data, client_config) - end - - # :ditto: - def browse( - browse_id : String, - *, # Force the following paramters to be passed by name - params : String, - client_config : ClientConfig | Nil = nil - ) - # JSON Request data, required by the API - data = { - "browseId" => browse_id, - "context" => self.make_context(client_config), - } - - # Append the additionnal parameters if those were provided - # (this is required for channel info, playlist and community, e.g) - if params != "" - data["params"] = params - end - - return self._post_json("/youtubei/v1/browse", data, client_config) - end - - #################################################################### - # next(continuation, client_config?) - # next(data, client_config?) - # - # Requests the youtubei/v1/next endpoint with the required headers - # and POST data in order to get a JSON reply in english that can - # be easily parsed. - # - # Both forms can take an optional ClientConfig parameter (see - # `struct ClientConfig` above for more details). - # - # The requested data can be: - # - # - A continuation token (ctoken). Depending on this token's - # contents, the returned data can be videos comments, - # their replies, ... In this case, the string must be passed - # directly to the function. E.g: - # - # ``` - # YoutubeAPI::next("ABCDEFGH_abcdefgh==") - # ``` - # - # - Arbitrary parameters, in Hash form. See examples below for - # known examples of arbitrary data that can be passed to YouTube: - # - # ``` - # # Get the videos related to a specific video ID - # YoutubeAPI::next({"videoId" => "dQw4w9WgXcQ"}) - # - # # Get a playlist video's details - # YoutubeAPI::next({ - # "videoId" => "9bZkp7q19f0", - # "playlistId" => "PL_oFlvgqkrjUVQwiiE3F3k3voF4tjXeP0", - # }) - # ``` - # - def next(continuation : String, *, client_config : ClientConfig | Nil = nil) - # JSON Request data, required by the API - data = { - "context" => self.make_context(client_config), - "continuation" => continuation, - } - - return self._post_json("/youtubei/v1/next", data, client_config) - end - - # :ditto: - def next(data : Hash, *, client_config : ClientConfig | Nil = nil) - # JSON Request data, required by the API - data2 = data.merge({ - "context" => self.make_context(client_config), - }) - - return self._post_json("/youtubei/v1/next", data2, client_config) - end - - # Allow a NamedTuple to be passed, too. - def next(data : NamedTuple, *, client_config : ClientConfig | Nil = nil) - return self.next(data.to_h, client_config: client_config) - end - - #################################################################### - # player(video_id, params, client_config?) - # - # Requests the youtubei/v1/player endpoint with the required headers - # and POST data in order to get a JSON reply. - # - # The requested data is a video ID (`v=` parameter), with some - # additional paramters, formatted as a base64 string. - # - # An optional ClientConfig parameter can be passed, too (see - # `struct ClientConfig` above for more details). - # - def player( - video_id : String, - *, # Force the following paramters to be passed by name - params : String, - client_config : ClientConfig | Nil = nil - ) - # JSON Request data, required by the API - data = { - "videoId" => video_id, - "context" => self.make_context(client_config), - } - - # Append the additionnal parameters if those were provided - if params != "" - data["params"] = params - end - - return self._post_json("/youtubei/v1/player", data, client_config) - end - - #################################################################### - # resolve_url(url, client_config?) - # - # Requests the youtubei/v1/navigation/resolve_url endpoint with the - # required headers and POST data in order to get a JSON reply. - # - # An optional ClientConfig parameter can be passed, too (see - # `struct ClientConfig` above for more details). - # - # Output: - # - # ``` - # # Valid channel "brand URL" gives the related UCID and browse ID - # channel_a = YoutubeAPI.resolve_url("https://youtube.com/c/google") - # channel_a # => { - # "endpoint": { - # "browseEndpoint": { - # "params": "EgC4AQA%3D", - # "browseId":"UCK8sQmJBp8GCxrOtXWBpyEA" - # }, - # ... - # } - # } - # - # # Invalid URL returns throws an InfoException - # channel_b = YoutubeAPI.resolve_url("https://youtube.com/c/invalid") - # ``` - # - def resolve_url(url : String, client_config : ClientConfig | Nil = nil) - data = { - "context" => self.make_context(nil), - "url" => url, - } - - return self._post_json("/youtubei/v1/navigation/resolve_url", data, client_config) - end - - #################################################################### - # search(search_query, params, client_config?) - # - # Requests the youtubei/v1/search endpoint with the required headers - # and POST data in order to get a JSON reply. As the search results - # vary depending on the region, a region code can be specified in - # order to get non-US results. - # - # The requested data is a search string, with some additional - # paramters, formatted as a base64 string. - # - # An optional ClientConfig parameter can be passed, too (see - # `struct ClientConfig` above for more details). - # - def search( - search_query : String, - params : String, - client_config : ClientConfig | Nil = nil - ) - # JSON Request data, required by the API - data = { - "query" => search_query, - "context" => self.make_context(client_config), - "params" => params, - } - - return self._post_json("/youtubei/v1/search", data, client_config) - end - - #################################################################### - # _post_json(endpoint, data, client_config?) - # - # Internal function that does the actual request to youtube servers - # and handles errors. - # - # The requested data is an endpoint (URL without the domain part) - # and the data as a Hash object. - # - def _post_json( - endpoint : String, - data : Hash, - client_config : ClientConfig | Nil - ) : Hash(String, JSON::Any) - # Use the default client config if nil is passed - client_config ||= DEFAULT_CLIENT_CONFIG - - # Query parameters - url = "#{endpoint}?key=#{client_config.api_key}" - - headers = HTTP::Headers{ - "Content-Type" => "application/json; charset=UTF-8", - "Accept-Encoding" => "gzip", - } - - # Logging - LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") - LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config.to_s}") - LOGGER.trace("YoutubeAPI: POST data: #{data.to_s}") - - # Send the POST request - if client_config.proxy_region - response = YT_POOL.client( - client_config.proxy_region, - &.post(url, headers: headers, body: data.to_json) - ) - else - response = YT_POOL.client &.post( - url, headers: headers, body: data.to_json - ) - end - - # Convert result to Hash - initial_data = JSON.parse(response.body).as_h - - # Error handling - if initial_data.has_key?("error") - code = initial_data["error"]["code"] - message = initial_data["error"]["message"].to_s.sub(/(\\n)+\^$/, "") - - # Logging - LOGGER.error("YoutubeAPI: Got error #{code} when requesting #{endpoint}") - LOGGER.error("YoutubeAPI: #{message}") - LOGGER.info("YoutubeAPI: POST data was: #{data.to_s}") - - raise InfoException.new("Could not extract JSON. Youtube API returned \ - error #{code} with message:
    \"#{message}\"") - end - - return initial_data - end -end # End of module diff --git a/src/invidious/yt_backend/proxy.cr b/src/invidious/yt_backend/proxy.cr new file mode 100644 index 00000000..3418d887 --- /dev/null +++ b/src/invidious/yt_backend/proxy.cr @@ -0,0 +1,316 @@ +# See https://github.com/crystal-lang/crystal/issues/2963 +class HTTPProxy + getter proxy_host : String + getter proxy_port : Int32 + getter options : Hash(Symbol, String) + getter tls : OpenSSL::SSL::Context::Client? + + def initialize(@proxy_host, @proxy_port = 80, @options = {} of Symbol => String) + end + + def open(host, port, tls = nil, connection_options = {} of Symbol => Float64 | Nil) + dns_timeout = connection_options.fetch(:dns_timeout, nil) + connect_timeout = connection_options.fetch(:connect_timeout, nil) + read_timeout = connection_options.fetch(:read_timeout, nil) + + socket = TCPSocket.new @proxy_host, @proxy_port, dns_timeout, connect_timeout + socket.read_timeout = read_timeout if read_timeout + socket.sync = true + + socket << "CONNECT #{host}:#{port} HTTP/1.1\r\n" + + if options[:user]? + credentials = Base64.strict_encode("#{options[:user]}:#{options[:password]}") + credentials = "#{credentials}\n".gsub(/\s/, "") + socket << "Proxy-Authorization: Basic #{credentials}\r\n" + end + + socket << "\r\n" + + resp = parse_response(socket) + + if resp[:code]? == 200 + {% if !flag?(:without_openssl) %} + if tls + tls_socket = OpenSSL::SSL::Socket::Client.new(socket, context: tls, sync_close: true, hostname: host) + socket = tls_socket + end + {% end %} + + return socket + else + socket.close + raise IO::Error.new(resp.inspect) + end + end + + private def parse_response(socket) + resp = {} of Symbol => Int32 | String | Hash(String, String) + + begin + version, code, reason = socket.gets.as(String).chomp.split(/ /, 3) + + headers = {} of String => String + + while (line = socket.gets.as(String)) && (line.chomp != "") + name, value = line.split(/:/, 2) + headers[name.strip] = value.strip + end + + resp[:version] = version + resp[:code] = code.to_i + resp[:reason] = reason + resp[:headers] = headers + rescue + end + + return resp + end +end + +class HTTPClient < HTTP::Client + def set_proxy(proxy : HTTPProxy) + begin + @io = proxy.open(host: @host, port: @port, tls: @tls, connection_options: proxy_connection_options) + rescue IO::Error + @io = nil + end + end + + def unset_proxy + @io = nil + end + + def proxy_connection_options + opts = {} of Symbol => Float64 | Nil + + opts[:dns_timeout] = @dns_timeout + opts[:connect_timeout] = @connect_timeout + opts[:read_timeout] = @read_timeout + + return opts + end +end + +def get_proxies(country_code = "US") + # return get_spys_proxies(country_code) + return get_nova_proxies(country_code) +end + +def filter_proxies(proxies) + proxies.select! do |proxy| + begin + client = HTTPClient.new(YT_URL) + client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + client.read_timeout = 10.seconds + client.connect_timeout = 10.seconds + + proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) + client.set_proxy(proxy) + + status_ok = client.head("/").status_code == 200 + client.close + status_ok + rescue ex + false + end + end + + return proxies +end + +def get_nova_proxies(country_code = "US") + country_code = country_code.downcase + client = HTTP::Client.new(URI.parse("https://www.proxynova.com")) + client.read_timeout = 10.seconds + client.connect_timeout = 10.seconds + + headers = HTTP::Headers.new + headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" + headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" + headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" + headers["Host"] = "www.proxynova.com" + headers["Origin"] = "https://www.proxynova.com" + headers["Referer"] = "https://www.proxynova.com/proxy-server-list/country-#{country_code}/" + + response = client.get("/proxy-server-list/country-#{country_code}/", headers) + client.close + document = XML.parse_html(response.body) + + proxies = [] of {ip: String, port: Int32, score: Float64} + document.xpath_nodes(%q(//tr[@data-proxy-id])).each do |node| + ip = node.xpath_node(%q(.//td/abbr/script)).not_nil!.content + ip = ip.match(/document\.write\('(?[^']+)'.substr\(8\) \+ '(?[^']+)'/).not_nil! + ip = "#{ip["sub1"][8..-1]}#{ip["sub2"]}" + port = node.xpath_node(%q(.//td[2])).not_nil!.content.strip.to_i + + anchor = node.xpath_node(%q(.//td[4]/div)).not_nil! + speed = anchor["data-value"].to_f + latency = anchor["title"].to_f + uptime = node.xpath_node(%q(.//td[5]/span)).not_nil!.content.rchop("%").to_f + + # TODO: Tweak me + score = (uptime*4 + speed*2 + latency)/7 + proxies << {ip: ip, port: port, score: score} + end + + # proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse + return proxies +end + +def get_spys_proxies(country_code = "US") + client = HTTP::Client.new(URI.parse("http://spys.one")) + client.read_timeout = 10.seconds + client.connect_timeout = 10.seconds + + headers = HTTP::Headers.new + headers["User-Agent"] = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/68.0.3440.106 Safari/537.36" + headers["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8" + headers["Accept-Language"] = "Accept-Language: en-US,en;q=0.9" + headers["Host"] = "spys.one" + headers["Origin"] = "http://spys.one" + headers["Referer"] = "http://spys.one/free-proxy-list/#{country_code}/" + headers["Content-Type"] = "application/x-www-form-urlencoded" + body = { + "xpp" => "5", + "xf1" => "0", + "xf2" => "0", + "xf4" => "0", + "xf5" => "1", + } + + response = client.post("/free-proxy-list/#{country_code}/", headers, form: body) + client.close + 20.times do + if response.status_code == 200 + break + end + response = client.post("/free-proxy-list/#{country_code}/", headers, form: body) + end + + response = XML.parse_html(response.body) + + mapping = response.xpath_node(%q(.//body/script)).not_nil!.content + mapping = mapping.match(/\}\('(?

    [^']+)',\d+,\d+,'(?[^']+)'/).not_nil! + p = mapping["p"].not_nil! + x = mapping["x"].not_nil! + mapping = decrypt_port(p, x) + + proxies = [] of {ip: String, port: Int32, score: Float64} + response = response.xpath_node(%q(//tr/td/table)).not_nil! + response.xpath_nodes(%q(.//tr)).each do |node| + if !node["onmouseover"]? + next + end + + ip = node.xpath_node(%q(.//td[1]/font[2])).to_s.match(/(?

    [^<]+)"\+(?[\d\D]+)\)$/).not_nil!["encrypted_port"] + + port = "" + encrypted_port.split("+").each do |number| + number = number.delete("()") + left_side, right_side = number.split("^") + result = mapping[left_side] ^ mapping[right_side] + port = "#{port}#{result}" + end + port = port.to_i + + latency = node.xpath_node(%q(.//td[6])).not_nil!.content.to_f + speed = node.xpath_node(%q(.//td[7]/font/table)).not_nil!["width"].to_f + uptime = node.xpath_node(%q(.//td[8]/font/acronym)).not_nil! + + # Skip proxies that are down + if uptime["title"].ends_with? "?" + next + end + + if md = uptime.content.match(/^\d+/) + uptime = md[0].to_f + else + next + end + + score = (uptime*4 + speed*2 + latency)/7 + + proxies << {ip: ip, port: port, score: score} + end + + proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse + return proxies +end + +def decrypt_port(p, x) + x = x.split("^") + s = {} of String => String + + 60.times do |i| + if x[i]?.try &.empty? + s[y_func(i)] = y_func(i) + else + s[y_func(i)] = x[i] + end + end + + x = s + p = p.gsub(/\b\w+\b/, x) + + p = p.split(";") + p = p.map { |item| item.split("=") } + + mapping = {} of String => Int32 + p.each do |item| + if item == [""] + next + end + + key = item[0] + value = item[1] + value = value.split("^") + + if value.size == 1 + value = value[0].to_i + else + left_side = value[0].to_i? + left_side ||= mapping[value[0]] + right_side = value[1].to_i? + right_side ||= mapping[value[1]] + + value = left_side ^ right_side + end + + mapping[key] = value + end + + return mapping +end + +def y_func(c) + return (c < 60 ? "" : y_func((c/60).to_i)) + ((c = c % 60) > 35 ? ((c.to_u8 + 29).unsafe_chr) : c.to_s(36)) +end + +PROXY_LIST = { + "GB" => [{ip: "147.135.206.233", port: 3128}, {ip: "167.114.180.102", port: 8080}, {ip: "176.35.250.108", port: 8080}, {ip: "5.148.128.44", port: 80}, {ip: "62.7.85.234", port: 8080}, {ip: "88.150.135.10", port: 36624}], + "DE" => [{ip: "138.201.223.250", port: 31288}, {ip: "138.68.73.59", port: 32574}, {ip: "159.69.211.173", port: 3128}, {ip: "173.249.43.105", port: 3128}, {ip: "212.202.244.90", port: 8080}, {ip: "5.56.18.35", port: 38827}], + "FR" => [{ip: "137.74.254.242", port: 3128}, {ip: "151.80.143.155", port: 53281}, {ip: "178.33.150.97", port: 3128}, {ip: "37.187.2.31", port: 3128}, {ip: "5.135.164.72", port: 3128}, {ip: "5.39.91.73", port: 3128}, {ip: "51.38.162.2", port: 32231}, {ip: "51.38.217.121", port: 808}, {ip: "51.75.109.81", port: 3128}, {ip: "51.75.109.82", port: 3128}, {ip: "51.75.109.83", port: 3128}, {ip: "51.75.109.84", port: 3128}, {ip: "51.75.109.86", port: 3128}, {ip: "51.75.109.88", port: 3128}, {ip: "51.75.109.90", port: 3128}, {ip: "62.210.167.3", port: 3128}, {ip: "90.63.218.232", port: 8080}, {ip: "91.134.165.198", port: 9999}], + "IN" => [{ip: "1.186.151.206", port: 36253}, {ip: "1.186.63.130", port: 39142}, {ip: "103.105.40.1", port: 16538}, {ip: "103.105.40.153", port: 16538}, {ip: "103.106.148.203", port: 60227}, {ip: "103.106.148.207", port: 51451}, {ip: "103.12.246.12", port: 8080}, {ip: "103.14.235.109", port: 8080}, {ip: "103.14.235.26", port: 8080}, {ip: "103.198.172.4", port: 50820}, {ip: "103.205.112.1", port: 23500}, {ip: "103.209.64.19", port: 6666}, {ip: "103.211.76.5", port: 8080}, {ip: "103.216.82.19", port: 6666}, {ip: "103.216.82.190", port: 6666}, {ip: "103.216.82.209", port: 54806}, {ip: "103.216.82.214", port: 6666}, {ip: "103.216.82.37", port: 6666}, {ip: "103.216.82.44", port: 8080}, {ip: "103.216.82.50", port: 53281}, {ip: "103.22.173.230", port: 8080}, {ip: "103.224.38.2", port: 83}, {ip: "103.226.142.90", port: 41386}, {ip: "103.236.114.38", port: 49638}, {ip: "103.240.161.107", port: 6666}, {ip: "103.240.161.108", port: 6666}, {ip: "103.240.161.109", port: 6666}, {ip: "103.240.161.59", port: 48809}, {ip: "103.245.198.101", port: 8080}, {ip: "103.250.148.82", port: 6666}, {ip: "103.251.58.51", port: 61489}, {ip: "103.253.169.115", port: 32731}, {ip: "103.253.211.182", port: 8080}, {ip: "103.253.211.182", port: 80}, {ip: "103.255.234.169", port: 39847}, {ip: "103.42.161.118", port: 8080}, {ip: "103.42.162.30", port: 8080}, {ip: "103.42.162.50", port: 8080}, {ip: "103.42.162.58", port: 8080}, {ip: "103.46.233.12", port: 83}, {ip: "103.46.233.13", port: 83}, {ip: "103.46.233.16", port: 83}, {ip: "103.46.233.17", port: 83}, {ip: "103.46.233.21", port: 83}, {ip: "103.46.233.23", port: 83}, {ip: "103.46.233.29", port: 81}, {ip: "103.46.233.29", port: 83}, {ip: "103.46.233.50", port: 83}, {ip: "103.47.153.87", port: 8080}, {ip: "103.47.66.2", port: 39804}, {ip: "103.49.53.1", port: 81}, {ip: "103.52.220.1", port: 49068}, {ip: "103.56.228.166", port: 53281}, {ip: "103.56.30.128", port: 8080}, {ip: "103.65.193.17", port: 50862}, {ip: "103.65.195.1", port: 33960}, {ip: "103.69.220.14", port: 3128}, {ip: "103.70.128.84", port: 8080}, {ip: "103.70.128.86", port: 8080}, {ip: "103.70.131.74", port: 8080}, {ip: "103.70.146.250", port: 59563}, {ip: "103.72.216.194", port: 38345}, {ip: "103.75.161.38", port: 21776}, {ip: "103.76.253.155", port: 3128}, {ip: "103.87.104.137", port: 8080}, {ip: "110.235.198.3", port: 57660}, {ip: "114.69.229.161", port: 8080}, {ip: "117.196.231.201", port: 37769}, {ip: "117.211.166.214", port: 3128}, {ip: "117.240.175.51", port: 3128}, {ip: "117.240.210.155", port: 53281}, {ip: "117.240.59.115", port: 36127}, {ip: "117.242.154.73", port: 33889}, {ip: "117.244.15.243", port: 3128}, {ip: "119.235.54.3", port: 8080}, {ip: "120.138.117.102", port: 59308}, {ip: "123.108.200.185", port: 83}, {ip: "123.108.200.217", port: 82}, {ip: "123.176.43.218", port: 40524}, {ip: "125.21.43.82", port: 8080}, {ip: "125.62.192.225", port: 82}, {ip: "125.62.192.33", port: 84}, {ip: "125.62.194.1", port: 83}, {ip: "125.62.213.134", port: 82}, {ip: "125.62.213.18", port: 83}, {ip: "125.62.213.201", port: 84}, {ip: "125.62.213.242", port: 83}, {ip: "125.62.214.185", port: 84}, {ip: "139.5.26.27", port: 53281}, {ip: "14.102.67.101", port: 30337}, {ip: "14.142.122.134", port: 8080}, {ip: "150.129.114.194", port: 6666}, {ip: "150.129.151.62", port: 6666}, {ip: "150.129.171.115", port: 6666}, {ip: "150.129.201.30", port: 6666}, {ip: "157.119.207.38", port: 53281}, {ip: "175.100.185.151", port: 53281}, {ip: "182.18.177.114", port: 56173}, {ip: "182.73.194.170", port: 8080}, {ip: "182.74.85.230", port: 51214}, {ip: "183.82.116.56", port: 8080}, {ip: "183.82.32.56", port: 49551}, {ip: "183.87.14.229", port: 53281}, {ip: "183.87.14.250", port: 44915}, {ip: "202.134.160.168", port: 8080}, {ip: "202.134.166.1", port: 8080}, {ip: "202.134.180.50", port: 8080}, {ip: "202.62.84.210", port: 53281}, {ip: "203.192.193.225", port: 8080}, {ip: "203.192.195.14", port: 31062}, {ip: "203.192.217.11", port: 8080}, {ip: "223.196.83.182", port: 53281}, {ip: "27.116.20.169", port: 36630}, {ip: "27.116.20.209", port: 36630}, {ip: "27.116.51.21", port: 36033}, {ip: "43.224.8.114", port: 50333}, {ip: "43.224.8.116", port: 6666}, {ip: "43.224.8.124", port: 6666}, {ip: "43.224.8.86", port: 6666}, {ip: "43.225.20.73", port: 8080}, {ip: "43.225.23.26", port: 8080}, {ip: "43.230.196.98", port: 36569}, {ip: "43.240.5.225", port: 31777}, {ip: "43.241.28.248", port: 8080}, {ip: "43.242.209.201", port: 8080}, {ip: "43.246.139.82", port: 8080}, {ip: "43.248.73.86", port: 53281}, {ip: "43.251.170.145", port: 54059}, {ip: "45.112.57.230", port: 61222}, {ip: "45.115.171.30", port: 47949}, {ip: "45.121.29.254", port: 54858}, {ip: "45.123.26.146", port: 53281}, {ip: "45.125.61.193", port: 32804}, {ip: "45.125.61.209", port: 32804}, {ip: "45.127.121.194", port: 53281}, {ip: "45.250.226.14", port: 3128}, {ip: "45.250.226.38", port: 8080}, {ip: "45.250.226.47", port: 8080}, {ip: "45.250.226.55", port: 8080}, {ip: "49.249.251.86", port: 53281}], + "CN" => [{ip: "182.61.170.45", port: 3128}], + "RU" => [{ip: "109.106.139.225", port: 45689}, {ip: "109.161.48.228", port: 53281}, {ip: "109.167.224.198", port: 51919}, {ip: "109.172.57.250", port: 23500}, {ip: "109.194.2.126", port: 61822}, {ip: "109.195.150.128", port: 37564}, {ip: "109.201.96.171", port: 31773}, {ip: "109.201.97.204", port: 41258}, {ip: "109.201.97.235", port: 39125}, {ip: "109.206.140.74", port: 45991}, {ip: "109.206.148.31", port: 30797}, {ip: "109.69.75.5", port: 46347}, {ip: "109.71.181.170", port: 53983}, {ip: "109.74.132.190", port: 42663}, {ip: "109.74.143.45", port: 36529}, {ip: "109.75.140.158", port: 59916}, {ip: "109.95.84.114", port: 52125}, {ip: "130.255.12.24", port: 31004}, {ip: "134.19.147.72", port: 44812}, {ip: "134.90.181.7", port: 54353}, {ip: "145.255.6.171", port: 31252}, {ip: "146.120.227.3", port: 8080}, {ip: "149.255.112.194", port: 48968}, {ip: "158.46.127.222", port: 52574}, {ip: "158.46.43.144", port: 39120}, {ip: "158.58.130.185", port: 50016}, {ip: "158.58.132.12", port: 56962}, {ip: "158.58.133.106", port: 41258}, {ip: "158.58.133.13", port: 21213}, {ip: "176.101.0.47", port: 34471}, {ip: "176.101.89.226", port: 33470}, {ip: "176.106.12.65", port: 30120}, {ip: "176.107.80.110", port: 58901}, {ip: "176.110.121.9", port: 46322}, {ip: "176.110.121.90", port: 21776}, {ip: "176.111.97.18", port: 8080}, {ip: "176.112.106.230", port: 33996}, {ip: "176.112.110.40", port: 61142}, {ip: "176.113.116.70", port: 55589}, {ip: "176.113.27.192", port: 47337}, {ip: "176.115.197.118", port: 8080}, {ip: "176.117.255.182", port: 53100}, {ip: "176.120.200.69", port: 44331}, {ip: "176.124.123.93", port: 41258}, {ip: "176.192.124.98", port: 60787}, {ip: "176.192.5.238", port: 61227}, {ip: "176.192.8.206", port: 39422}, {ip: "176.193.15.94", port: 8080}, {ip: "176.196.195.170", port: 48129}, {ip: "176.196.198.154", port: 35252}, {ip: "176.196.238.234", port: 44648}, {ip: "176.196.239.46", port: 35656}, {ip: "176.196.246.6", port: 53281}, {ip: "176.196.84.138", port: 51336}, {ip: "176.197.145.246", port: 32649}, {ip: "176.197.99.142", port: 47278}, {ip: "176.215.1.108", port: 60339}, {ip: "176.215.170.147", port: 35604}, {ip: "176.56.23.14", port: 35340}, {ip: "176.62.185.54", port: 53883}, {ip: "176.74.13.110", port: 8080}, {ip: "178.130.29.226", port: 53295}, {ip: "178.170.254.178", port: 46788}, {ip: "178.213.13.136", port: 53281}, {ip: "178.218.104.8", port: 49707}, {ip: "178.219.183.163", port: 8080}, {ip: "178.237.180.34", port: 57307}, {ip: "178.57.101.212", port: 38020}, {ip: "178.57.101.235", port: 31309}, {ip: "178.64.190.133", port: 46688}, {ip: "178.75.1.111", port: 50411}, {ip: "178.75.27.131", port: 41879}, {ip: "185.13.35.178", port: 40654}, {ip: "185.15.189.67", port: 30215}, {ip: "185.175.119.137", port: 41258}, {ip: "185.18.111.194", port: 41258}, {ip: "185.19.176.237", port: 53281}, {ip: "185.190.40.115", port: 31747}, {ip: "185.216.195.134", port: 61287}, {ip: "185.22.172.94", port: 10010}, {ip: "185.22.172.94", port: 1448}, {ip: "185.22.174.65", port: 10010}, {ip: "185.22.174.65", port: 1448}, {ip: "185.23.64.100", port: 3130}, {ip: "185.23.82.39", port: 59248}, {ip: "185.233.94.105", port: 59288}, {ip: "185.233.94.146", port: 57736}, {ip: "185.3.68.54", port: 53500}, {ip: "185.32.120.177", port: 60724}, {ip: "185.34.20.164", port: 53700}, {ip: "185.34.23.43", port: 63238}, {ip: "185.51.60.141", port: 39935}, {ip: "185.61.92.228", port: 33060}, {ip: "185.61.93.67", port: 49107}, {ip: "185.7.233.66", port: 53504}, {ip: "185.72.225.10", port: 56285}, {ip: "185.75.5.158", port: 60819}, {ip: "185.9.86.186", port: 39345}, {ip: "188.133.136.10", port: 47113}, {ip: "188.168.75.254", port: 56899}, {ip: "188.170.41.6", port: 60332}, {ip: "188.187.189.142", port: 38264}, {ip: "188.234.151.103", port: 8080}, {ip: "188.235.11.88", port: 57143}, {ip: "188.235.137.196", port: 23500}, {ip: "188.244.175.2", port: 8080}, {ip: "188.255.82.136", port: 53281}, {ip: "188.43.4.117", port: 60577}, {ip: "188.68.95.166", port: 41258}, {ip: "188.92.242.180", port: 52048}, {ip: "188.93.242.213", port: 49774}, {ip: "192.162.193.243", port: 36910}, {ip: "192.162.214.11", port: 41258}, {ip: "193.106.170.133", port: 38591}, {ip: "193.232.113.244", port: 40412}, {ip: "193.232.234.130", port: 61932}, {ip: "193.242.177.105", port: 53281}, {ip: "193.242.178.50", port: 52376}, {ip: "193.242.178.90", port: 8080}, {ip: "193.33.101.152", port: 34611}, {ip: "194.114.128.149", port: 61213}, {ip: "194.135.15.146", port: 59328}, {ip: "194.135.216.178", port: 56805}, {ip: "194.135.75.74", port: 41258}, {ip: "194.146.201.67", port: 53281}, {ip: "194.186.18.46", port: 56408}, {ip: "194.186.20.62", port: 21231}, {ip: "194.190.171.214", port: 43960}, {ip: "194.9.27.82", port: 42720}, {ip: "195.133.232.58", port: 41733}, {ip: "195.14.114.116", port: 59530}, {ip: "195.14.114.24", port: 56897}, {ip: "195.158.250.97", port: 41582}, {ip: "195.16.48.142", port: 36083}, {ip: "195.191.183.169", port: 47238}, {ip: "195.206.45.112", port: 53281}, {ip: "195.208.172.70", port: 8080}, {ip: "195.209.141.67", port: 31927}, {ip: "195.209.176.2", port: 8080}, {ip: "195.210.144.166", port: 30088}, {ip: "195.211.160.88", port: 44464}, {ip: "195.218.144.182", port: 31705}, {ip: "195.46.168.147", port: 8080}, {ip: "195.9.188.78", port: 53281}, {ip: "195.9.209.10", port: 35242}, {ip: "195.9.223.246", port: 52098}, {ip: "195.9.237.66", port: 8080}, {ip: "195.9.91.66", port: 33199}, {ip: "195.91.132.20", port: 19600}, {ip: "195.98.183.82", port: 30953}, {ip: "212.104.82.246", port: 36495}, {ip: "212.119.229.18", port: 33852}, {ip: "212.13.97.122", port: 30466}, {ip: "212.19.21.19", port: 53264}, {ip: "212.19.5.157", port: 58442}, {ip: "212.19.8.223", port: 30281}, {ip: "212.19.8.239", port: 55602}, {ip: "212.192.202.207", port: 4550}, {ip: "212.22.80.224", port: 34822}, {ip: "212.26.247.178", port: 38418}, {ip: "212.33.228.161", port: 37971}, {ip: "212.33.243.83", port: 38605}, {ip: "212.34.53.126", port: 44369}, {ip: "212.5.107.81", port: 56481}, {ip: "212.7.230.7", port: 51405}, {ip: "212.77.138.161", port: 41258}, {ip: "213.108.221.201", port: 32800}, {ip: "213.109.7.135", port: 59918}, {ip: "213.128.9.204", port: 35549}, {ip: "213.134.196.12", port: 38723}, {ip: "213.168.37.86", port: 8080}, {ip: "213.187.118.184", port: 53281}, {ip: "213.21.23.98", port: 53281}, {ip: "213.210.67.166", port: 53281}, {ip: "213.234.0.242", port: 56503}, {ip: "213.247.192.131", port: 41258}, {ip: "213.251.226.208", port: 56900}, {ip: "213.33.155.80", port: 44387}, {ip: "213.33.199.194", port: 36411}, {ip: "213.33.224.82", port: 8080}, {ip: "213.59.153.19", port: 53281}, {ip: "217.10.45.103", port: 8080}, {ip: "217.107.197.39", port: 33628}, {ip: "217.116.60.66", port: 21231}, {ip: "217.195.87.58", port: 41258}, {ip: "217.197.239.54", port: 34463}, {ip: "217.74.161.42", port: 34175}, {ip: "217.8.84.76", port: 46378}, {ip: "31.131.67.14", port: 8080}, {ip: "31.132.127.142", port: 35432}, {ip: "31.132.218.252", port: 32423}, {ip: "31.173.17.118", port: 51317}, {ip: "31.193.124.70", port: 53281}, {ip: "31.210.211.147", port: 8080}, {ip: "31.220.183.217", port: 53281}, {ip: "31.29.212.82", port: 35066}, {ip: "31.42.254.24", port: 30912}, {ip: "31.47.189.14", port: 38473}, {ip: "37.113.129.98", port: 41665}, {ip: "37.192.103.164", port: 34835}, {ip: "37.192.194.50", port: 50165}, {ip: "37.192.99.151", port: 51417}, {ip: "37.205.83.91", port: 35888}, {ip: "37.233.85.155", port: 53281}, {ip: "37.235.167.66", port: 53281}, {ip: "37.235.65.2", port: 47816}, {ip: "37.235.67.178", port: 34450}, {ip: "37.9.134.133", port: 41262}, {ip: "46.150.174.90", port: 53281}, {ip: "46.151.156.198", port: 56013}, {ip: "46.16.226.10", port: 8080}, {ip: "46.163.131.55", port: 48306}, {ip: "46.173.191.51", port: 53281}, {ip: "46.174.222.61", port: 34977}, {ip: "46.180.96.79", port: 42319}, {ip: "46.181.151.79", port: 39386}, {ip: "46.21.74.130", port: 8080}, {ip: "46.227.162.98", port: 51558}, {ip: "46.229.187.169", port: 53281}, {ip: "46.229.67.198", port: 47437}, {ip: "46.243.179.221", port: 41598}, {ip: "46.254.217.54", port: 53281}, {ip: "46.32.68.188", port: 39707}, {ip: "46.39.224.112", port: 36765}, {ip: "46.63.162.171", port: 8080}, {ip: "46.73.33.253", port: 8080}, {ip: "5.128.32.12", port: 51959}, {ip: "5.129.155.3", port: 51390}, {ip: "5.129.16.27", port: 48935}, {ip: "5.141.81.65", port: 61853}, {ip: "5.16.15.234", port: 8080}, {ip: "5.167.51.235", port: 8080}, {ip: "5.167.96.238", port: 3128}, {ip: "5.19.165.235", port: 30793}, {ip: "5.35.93.157", port: 31773}, {ip: "5.59.137.90", port: 8888}, {ip: "5.8.207.160", port: 57192}, {ip: "62.122.97.66", port: 59143}, {ip: "62.148.151.253", port: 53570}, {ip: "62.152.85.158", port: 31156}, {ip: "62.165.54.153", port: 55522}, {ip: "62.173.140.14", port: 8080}, {ip: "62.173.155.206", port: 41258}, {ip: "62.182.206.19", port: 37715}, {ip: "62.213.14.166", port: 8080}, {ip: "62.76.123.224", port: 8080}, {ip: "77.221.220.133", port: 44331}, {ip: "77.232.153.248", port: 60950}, {ip: "77.233.10.37", port: 54210}, {ip: "77.244.27.109", port: 47554}, {ip: "77.37.142.203", port: 53281}, {ip: "77.39.29.29", port: 49243}, {ip: "77.75.6.34", port: 8080}, {ip: "77.87.102.7", port: 42601}, {ip: "77.94.121.212", port: 36896}, {ip: "77.94.121.51", port: 45293}, {ip: "78.110.154.177", port: 59888}, {ip: "78.140.201.226", port: 8090}, {ip: "78.153.4.122", port: 9001}, {ip: "78.156.225.170", port: 41258}, {ip: "78.156.243.146", port: 59730}, {ip: "78.29.14.201", port: 39001}, {ip: "78.81.24.112", port: 8080}, {ip: "78.85.36.203", port: 8080}, {ip: "79.104.219.125", port: 3128}, {ip: "79.104.55.134", port: 8080}, {ip: "79.137.181.170", port: 8080}, {ip: "79.173.124.194", port: 47832}, {ip: "79.173.124.207", port: 53281}, {ip: "79.174.186.168", port: 45710}, {ip: "79.175.51.13", port: 54853}, {ip: "79.175.57.77", port: 55477}, {ip: "80.234.107.118", port: 56952}, {ip: "80.237.6.1", port: 34880}, {ip: "80.243.14.182", port: 49320}, {ip: "80.251.48.215", port: 45157}, {ip: "80.254.121.66", port: 41055}, {ip: "80.254.125.236", port: 80}, {ip: "80.72.121.185", port: 52379}, {ip: "80.89.133.210", port: 3128}, {ip: "80.91.17.113", port: 41258}, {ip: "81.162.61.166", port: 40392}, {ip: "81.163.57.121", port: 41258}, {ip: "81.163.57.46", port: 41258}, {ip: "81.163.62.136", port: 41258}, {ip: "81.23.112.98", port: 55269}, {ip: "81.23.118.106", port: 60427}, {ip: "81.23.177.245", port: 8080}, {ip: "81.24.126.166", port: 8080}, {ip: "81.30.216.147", port: 41258}, {ip: "81.95.131.10", port: 44292}, {ip: "82.114.125.22", port: 8080}, {ip: "82.151.208.20", port: 8080}, {ip: "83.221.216.110", port: 47326}, {ip: "83.246.139.24", port: 8080}, {ip: "83.97.108.8", port: 41258}, {ip: "84.22.154.76", port: 8080}, {ip: "84.52.110.36", port: 38674}, {ip: "84.52.74.194", port: 8080}, {ip: "84.52.77.227", port: 41806}, {ip: "84.52.79.166", port: 43548}, {ip: "84.52.84.157", port: 44331}, {ip: "84.52.88.125", port: 32666}, {ip: "85.113.48.148", port: 8080}, {ip: "85.113.49.220", port: 8080}, {ip: "85.12.193.210", port: 58470}, {ip: "85.15.179.5", port: 8080}, {ip: "85.173.244.102", port: 53281}, {ip: "85.174.227.52", port: 59280}, {ip: "85.192.184.133", port: 8080}, {ip: "85.192.184.133", port: 80}, {ip: "85.21.240.193", port: 55820}, {ip: "85.21.63.219", port: 53281}, {ip: "85.235.190.18", port: 42494}, {ip: "85.237.56.193", port: 8080}, {ip: "85.91.119.6", port: 8080}, {ip: "86.102.116.30", port: 8080}, {ip: "86.110.30.146", port: 38109}, {ip: "87.117.3.129", port: 3128}, {ip: "87.225.108.195", port: 8080}, {ip: "87.228.103.111", port: 8080}, {ip: "87.228.103.43", port: 8080}, {ip: "87.229.143.10", port: 48872}, {ip: "87.249.205.103", port: 8080}, {ip: "87.249.21.193", port: 43079}, {ip: "87.255.13.217", port: 8080}, {ip: "88.147.159.167", port: 53281}, {ip: "88.200.225.32", port: 38583}, {ip: "88.204.59.177", port: 32666}, {ip: "88.84.209.69", port: 30819}, {ip: "88.87.72.72", port: 8080}, {ip: "88.87.79.20", port: 8080}, {ip: "88.87.91.163", port: 48513}, {ip: "88.87.93.20", port: 33277}, {ip: "89.109.12.82", port: 47972}, {ip: "89.109.21.43", port: 9090}, {ip: "89.109.239.183", port: 41041}, {ip: "89.109.54.137", port: 36469}, {ip: "89.17.37.218", port: 52957}, {ip: "89.189.130.103", port: 32626}, {ip: "89.189.159.214", port: 42530}, {ip: "89.189.174.121", port: 52636}, {ip: "89.23.18.29", port: 53281}, {ip: "89.249.251.21", port: 3128}, {ip: "89.250.149.114", port: 60981}, {ip: "89.250.17.209", port: 8080}, {ip: "89.250.19.173", port: 8080}, {ip: "90.150.87.172", port: 81}, {ip: "90.154.125.173", port: 33078}, {ip: "90.188.38.81", port: 60585}, {ip: "90.189.151.183", port: 32601}, {ip: "91.103.208.114", port: 57063}, {ip: "91.122.100.222", port: 44331}, {ip: "91.122.207.229", port: 8080}, {ip: "91.144.139.93", port: 3128}, {ip: "91.144.142.19", port: 44617}, {ip: "91.146.16.54", port: 57902}, {ip: "91.190.116.194", port: 38783}, {ip: "91.190.80.100", port: 31659}, {ip: "91.190.85.97", port: 34286}, {ip: "91.203.36.188", port: 8080}, {ip: "91.205.131.102", port: 8080}, {ip: "91.205.146.25", port: 37501}, {ip: "91.210.94.212", port: 52635}, {ip: "91.213.23.110", port: 8080}, {ip: "91.215.22.51", port: 53305}, {ip: "91.217.42.3", port: 8080}, {ip: "91.217.42.4", port: 8080}, {ip: "91.220.135.146", port: 41258}, {ip: "91.222.167.213", port: 38057}, {ip: "91.226.140.71", port: 33199}, {ip: "91.235.7.216", port: 59067}, {ip: "92.124.195.22", port: 3128}, {ip: "92.126.193.180", port: 8080}, {ip: "92.241.110.223", port: 53281}, {ip: "92.252.240.1", port: 53281}, {ip: "92.255.164.187", port: 3128}, {ip: "92.255.195.57", port: 53281}, {ip: "92.255.229.146", port: 55785}, {ip: "92.255.5.2", port: 41012}, {ip: "92.38.32.36", port: 56113}, {ip: "92.39.138.98", port: 31150}, {ip: "92.51.16.155", port: 46202}, {ip: "92.55.59.63", port: 33030}, {ip: "93.170.112.200", port: 47995}, {ip: "93.183.86.185", port: 53281}, {ip: "93.188.45.157", port: 8080}, {ip: "93.81.246.5", port: 53281}, {ip: "93.91.112.247", port: 41258}, {ip: "94.127.217.66", port: 40115}, {ip: "94.154.85.214", port: 8080}, {ip: "94.180.106.94", port: 32767}, {ip: "94.180.249.187", port: 38051}, {ip: "94.230.243.6", port: 8080}, {ip: "94.232.57.231", port: 51064}, {ip: "94.24.244.170", port: 48936}, {ip: "94.242.55.108", port: 10010}, {ip: "94.242.55.108", port: 1448}, {ip: "94.242.57.136", port: 10010}, {ip: "94.242.57.136", port: 1448}, {ip: "94.242.58.108", port: 10010}, {ip: "94.242.58.108", port: 1448}, {ip: "94.242.58.14", port: 10010}, {ip: "94.242.58.14", port: 1448}, {ip: "94.242.58.142", port: 10010}, {ip: "94.242.58.142", port: 1448}, {ip: "94.242.59.245", port: 10010}, {ip: "94.242.59.245", port: 1448}, {ip: "94.247.241.70", port: 53640}, {ip: "94.247.62.165", port: 33176}, {ip: "94.253.13.228", port: 54935}, {ip: "94.253.14.187", port: 55045}, {ip: "94.28.94.154", port: 46966}, {ip: "94.73.217.125", port: 40858}, {ip: "95.140.19.9", port: 8080}, {ip: "95.140.20.94", port: 33994}, {ip: "95.154.137.66", port: 41258}, {ip: "95.154.159.119", port: 44242}, {ip: "95.154.82.254", port: 52484}, {ip: "95.161.157.227", port: 43170}, {ip: "95.161.182.146", port: 33877}, {ip: "95.161.189.26", port: 61522}, {ip: "95.165.163.146", port: 8888}, {ip: "95.165.172.90", port: 60496}, {ip: "95.165.182.18", port: 38950}, {ip: "95.165.203.222", port: 33805}, {ip: "95.165.244.122", port: 58162}, {ip: "95.167.123.54", port: 58664}, {ip: "95.167.241.242", port: 49636}, {ip: "95.171.1.92", port: 35956}, {ip: "95.172.52.230", port: 35989}, {ip: "95.181.35.30", port: 40804}, {ip: "95.181.56.178", port: 39144}, {ip: "95.181.75.228", port: 53281}, {ip: "95.188.74.194", port: 57122}, {ip: "95.189.112.214", port: 35508}, {ip: "95.31.10.247", port: 30711}, {ip: "95.31.197.77", port: 41651}, {ip: "95.31.2.199", port: 33632}, {ip: "95.71.125.50", port: 49882}, {ip: "95.73.62.13", port: 32185}, {ip: "95.79.36.55", port: 44861}, {ip: "95.79.55.196", port: 53281}, {ip: "95.79.99.148", port: 3128}, {ip: "95.80.65.39", port: 43555}, {ip: "95.80.93.44", port: 41258}, {ip: "95.80.98.41", port: 8080}, {ip: "95.83.156.250", port: 58438}, {ip: "95.84.128.25", port: 33765}, {ip: "95.84.154.73", port: 57423}], + "CA" => [{ip: "144.217.161.149", port: 8080}, {ip: "24.37.9.6", port: 54154}, {ip: "54.39.138.144", port: 3128}, {ip: "54.39.138.145", port: 3128}, {ip: "54.39.138.151", port: 3128}, {ip: "54.39.138.152", port: 3128}, {ip: "54.39.138.153", port: 3128}, {ip: "54.39.138.154", port: 3128}, {ip: "54.39.138.155", port: 3128}, {ip: "54.39.138.156", port: 3128}, {ip: "54.39.138.157", port: 3128}, {ip: "54.39.53.104", port: 3128}, {ip: "66.70.167.113", port: 3128}, {ip: "66.70.167.116", port: 3128}, {ip: "66.70.167.117", port: 3128}, {ip: "66.70.167.119", port: 3128}, {ip: "66.70.167.120", port: 3128}, {ip: "66.70.167.125", port: 3128}, {ip: "66.70.188.148", port: 3128}, {ip: "70.35.213.229", port: 36127}, {ip: "70.65.233.174", port: 8080}, {ip: "72.139.24.66", port: 38861}, {ip: "74.15.191.160", port: 41564}], + "JP" => [{ip: "47.91.20.67", port: 8080}, {ip: "61.118.35.94", port: 55725}], + "IT" => [{ip: "109.70.201.97", port: 53517}, {ip: "176.31.82.212", port: 8080}, {ip: "185.132.228.118", port: 55583}, {ip: "185.49.58.88", port: 56006}, {ip: "185.94.89.179", port: 41258}, {ip: "213.203.134.10", port: 41258}, {ip: "217.61.172.12", port: 41369}, {ip: "46.232.143.126", port: 41258}, {ip: "46.232.143.253", port: 41258}, {ip: "93.67.154.125", port: 8080}, {ip: "93.67.154.125", port: 80}, {ip: "95.169.95.242", port: 53803}], + "TH" => [{ip: "1.10.184.166", port: 57330}, {ip: "1.10.186.100", port: 55011}, {ip: "1.10.186.209", port: 32431}, {ip: "1.10.186.245", port: 34360}, {ip: "1.10.186.93", port: 53711}, {ip: "1.10.187.118", port: 62000}, {ip: "1.10.187.34", port: 51635}, {ip: "1.10.187.43", port: 38715}, {ip: "1.10.188.181", port: 51093}, {ip: "1.10.188.83", port: 31940}, {ip: "1.10.188.95", port: 30593}, {ip: "1.10.189.58", port: 48564}, {ip: "1.179.157.237", port: 46178}, {ip: "1.179.164.213", port: 8080}, {ip: "1.179.198.37", port: 8080}, {ip: "1.20.100.99", port: 53794}, {ip: "1.20.101.221", port: 55707}, {ip: "1.20.101.254", port: 35394}, {ip: "1.20.101.80", port: 36234}, {ip: "1.20.102.133", port: 40296}, {ip: "1.20.103.13", port: 40544}, {ip: "1.20.103.56", port: 55422}, {ip: "1.20.96.234", port: 53142}, {ip: "1.20.97.54", port: 60122}, {ip: "1.20.99.63", port: 32123}, {ip: "101.108.92.20", port: 8080}, {ip: "101.109.143.71", port: 36127}, {ip: "101.51.141.110", port: 42860}, {ip: "101.51.141.60", port: 60417}, {ip: "103.246.17.237", port: 3128}, {ip: "110.164.73.131", port: 8080}, {ip: "110.164.87.80", port: 35844}, {ip: "110.77.134.106", port: 8080}, {ip: "113.53.29.92", port: 47297}, {ip: "113.53.83.192", port: 32780}, {ip: "113.53.83.195", port: 35686}, {ip: "113.53.91.214", port: 8080}, {ip: "115.87.27.0", port: 53276}, {ip: "118.172.211.3", port: 58535}, {ip: "118.172.211.40", port: 30430}, {ip: "118.174.196.174", port: 23500}, {ip: "118.174.196.203", port: 23500}, {ip: "118.174.220.107", port: 41222}, {ip: "118.174.220.110", port: 39025}, {ip: "118.174.220.115", port: 41011}, {ip: "118.174.220.118", port: 59556}, {ip: "118.174.220.136", port: 55041}, {ip: "118.174.220.163", port: 31561}, {ip: "118.174.220.168", port: 47455}, {ip: "118.174.220.231", port: 40924}, {ip: "118.174.220.238", port: 46326}, {ip: "118.174.234.13", port: 53084}, {ip: "118.174.234.26", port: 41926}, {ip: "118.174.234.32", port: 57403}, {ip: "118.174.234.59", port: 59149}, {ip: "118.174.234.68", port: 42626}, {ip: "118.174.234.83", port: 38006}, {ip: "118.175.207.104", port: 38959}, {ip: "118.175.244.111", port: 8080}, {ip: "118.175.93.207", port: 50738}, {ip: "122.154.38.53", port: 8080}, {ip: "122.154.59.6", port: 8080}, {ip: "122.154.72.102", port: 8080}, {ip: "122.155.222.98", port: 3128}, {ip: "124.121.22.121", port: 61699}, {ip: "125.24.156.16", port: 44321}, {ip: "125.25.165.105", port: 33850}, {ip: "125.25.165.111", port: 40808}, {ip: "125.25.165.42", port: 47221}, {ip: "125.25.201.14", port: 30100}, {ip: "125.26.99.135", port: 55637}, {ip: "125.26.99.141", port: 38537}, {ip: "125.26.99.148", port: 31818}, {ip: "134.236.247.137", port: 8080}, {ip: "159.192.98.224", port: 3128}, {ip: "171.100.2.154", port: 8080}, {ip: "171.100.9.126", port: 49163}, {ip: "180.180.156.116", port: 48431}, {ip: "180.180.156.46", port: 48507}, {ip: "180.180.156.87", port: 36628}, {ip: "180.180.218.204", port: 51565}, {ip: "180.180.8.34", port: 8080}, {ip: "182.52.238.125", port: 58861}, {ip: "182.52.74.73", port: 36286}, {ip: "182.52.74.76", port: 34084}, {ip: "182.52.74.77", port: 34825}, {ip: "182.52.74.78", port: 48708}, {ip: "182.52.90.45", port: 53799}, {ip: "182.53.206.155", port: 34307}, {ip: "182.53.206.43", port: 45330}, {ip: "182.53.206.49", port: 54228}, {ip: "183.88.212.141", port: 8080}, {ip: "183.88.212.184", port: 8080}, {ip: "183.88.213.85", port: 8080}, {ip: "183.88.214.47", port: 8080}, {ip: "184.82.128.211", port: 8080}, {ip: "202.183.201.13", port: 8081}, {ip: "202.29.20.151", port: 43083}, {ip: "203.150.172.151", port: 8080}, {ip: "27.131.157.94", port: 8080}, {ip: "27.145.100.22", port: 8080}, {ip: "27.145.100.243", port: 8080}, {ip: "49.231.196.114", port: 53281}, {ip: "58.97.72.83", port: 8080}, {ip: "61.19.145.66", port: 8080}], + "ES" => [{ip: "185.198.184.14", port: 48122}, {ip: "185.26.226.241", port: 36012}, {ip: "194.224.188.82", port: 3128}, {ip: "195.235.68.61", port: 3128}, {ip: "195.53.237.122", port: 3128}, {ip: "195.53.86.82", port: 3128}, {ip: "213.96.245.47", port: 8080}, {ip: "217.125.71.214", port: 33950}, {ip: "62.14.178.72", port: 53281}, {ip: "80.35.254.42", port: 53281}, {ip: "81.33.4.214", port: 61711}, {ip: "83.175.238.170", port: 53281}, {ip: "85.217.137.77", port: 3128}, {ip: "90.170.205.178", port: 33680}, {ip: "93.156.177.91", port: 53281}, {ip: "95.60.152.139", port: 37995}], + "AE" => [{ip: "178.32.5.90", port: 36159}], + "KR" => [{ip: "112.217.219.179", port: 3128}, {ip: "114.141.229.2", port: 58115}, {ip: "121.139.218.165", port: 31409}, {ip: "122.49.112.2", port: 38592}, {ip: "61.42.18.132", port: 53281}], + "BR" => [{ip: "128.201.97.157", port: 53281}, {ip: "128.201.97.158", port: 53281}, {ip: "131.0.246.157", port: 35252}, {ip: "131.161.26.90", port: 8080}, {ip: "131.72.143.100", port: 41396}, {ip: "138.0.24.66", port: 53281}, {ip: "138.121.130.50", port: 50600}, {ip: "138.121.155.127", port: 61932}, {ip: "138.121.32.133", port: 23492}, {ip: "138.185.176.63", port: 53281}, {ip: "138.204.233.190", port: 53281}, {ip: "138.204.233.242", port: 53281}, {ip: "138.219.71.74", port: 52688}, {ip: "138.36.107.24", port: 41184}, {ip: "138.94.115.166", port: 8080}, {ip: "143.0.188.161", port: 53281}, {ip: "143.202.218.135", port: 8080}, {ip: "143.208.2.42", port: 53281}, {ip: "143.208.79.223", port: 8080}, {ip: "143.255.52.102", port: 40687}, {ip: "143.255.52.116", port: 57856}, {ip: "143.255.52.117", port: 37279}, {ip: "144.217.22.128", port: 8080}, {ip: "168.0.8.225", port: 8080}, {ip: "168.0.8.55", port: 8080}, {ip: "168.121.139.54", port: 40056}, {ip: "168.181.168.23", port: 53281}, {ip: "168.181.170.198", port: 31935}, {ip: "168.232.198.25", port: 32009}, {ip: "168.232.198.35", port: 42267}, {ip: "168.232.207.145", port: 46342}, {ip: "170.0.104.107", port: 60337}, {ip: "170.0.112.2", port: 50359}, {ip: "170.0.112.229", port: 50359}, {ip: "170.238.118.107", port: 34314}, {ip: "170.239.144.9", port: 3128}, {ip: "170.247.29.138", port: 8080}, {ip: "170.81.237.36", port: 37124}, {ip: "170.84.51.74", port: 53281}, {ip: "170.84.60.222", port: 42981}, {ip: "177.10.202.67", port: 8080}, {ip: "177.101.60.86", port: 80}, {ip: "177.103.231.211", port: 55091}, {ip: "177.12.80.50", port: 50556}, {ip: "177.131.13.9", port: 20183}, {ip: "177.135.178.115", port: 42510}, {ip: "177.135.248.75", port: 20183}, {ip: "177.184.206.238", port: 39508}, {ip: "177.185.148.46", port: 58623}, {ip: "177.200.83.238", port: 8080}, {ip: "177.21.24.146", port: 666}, {ip: "177.220.188.120", port: 47556}, {ip: "177.220.188.213", port: 8080}, {ip: "177.222.229.243", port: 23500}, {ip: "177.234.161.42", port: 8080}, {ip: "177.36.11.241", port: 3128}, {ip: "177.36.12.193", port: 23500}, {ip: "177.37.199.175", port: 49608}, {ip: "177.39.187.70", port: 37315}, {ip: "177.44.175.199", port: 8080}, {ip: "177.46.148.126", port: 3128}, {ip: "177.46.148.142", port: 3128}, {ip: "177.47.194.98", port: 21231}, {ip: "177.5.98.58", port: 20183}, {ip: "177.52.55.19", port: 60901}, {ip: "177.54.200.66", port: 57526}, {ip: "177.55.255.74", port: 37147}, {ip: "177.67.217.94", port: 53281}, {ip: "177.73.248.6", port: 54381}, {ip: "177.73.4.234", port: 23500}, {ip: "177.75.143.211", port: 35955}, {ip: "177.75.161.206", port: 3128}, {ip: "177.75.86.49", port: 20183}, {ip: "177.8.216.106", port: 8080}, {ip: "177.8.216.114", port: 8080}, {ip: "177.8.37.247", port: 56052}, {ip: "177.84.216.17", port: 50569}, {ip: "177.85.200.254", port: 53095}, {ip: "177.87.169.1", port: 53281}, {ip: "179.107.97.178", port: 3128}, {ip: "179.109.144.25", port: 8080}, {ip: "179.109.193.137", port: 53281}, {ip: "179.189.125.206", port: 8080}, {ip: "179.97.30.46", port: 53100}, {ip: "186.192.195.220", port: 38983}, {ip: "186.193.11.226", port: 48999}, {ip: "186.193.26.106", port: 3128}, {ip: "186.208.220.248", port: 3128}, {ip: "186.209.243.142", port: 3128}, {ip: "186.209.243.233", port: 3128}, {ip: "186.211.106.227", port: 34334}, {ip: "186.211.160.178", port: 36756}, {ip: "186.215.133.170", port: 20183}, {ip: "186.216.81.21", port: 31773}, {ip: "186.219.214.13", port: 32708}, {ip: "186.224.94.6", port: 48957}, {ip: "186.225.97.246", port: 43082}, {ip: "186.226.171.163", port: 48698}, {ip: "186.226.179.2", port: 56089}, {ip: "186.226.234.67", port: 33834}, {ip: "186.228.147.58", port: 20183}, {ip: "186.233.97.163", port: 8888}, {ip: "186.248.170.82", port: 53281}, {ip: "186.249.213.101", port: 53482}, {ip: "186.249.213.65", port: 52018}, {ip: "186.250.213.225", port: 60774}, {ip: "186.250.96.70", port: 8080}, {ip: "186.250.96.77", port: 8080}, {ip: "187.1.43.246", port: 53396}, {ip: "187.108.36.250", port: 20183}, {ip: "187.108.38.10", port: 20183}, {ip: "187.109.36.251", port: 20183}, {ip: "187.109.40.9", port: 20183}, {ip: "187.109.56.101", port: 20183}, {ip: "187.111.90.89", port: 53281}, {ip: "187.115.10.50", port: 20183}, {ip: "187.19.62.7", port: 59010}, {ip: "187.33.79.61", port: 33469}, {ip: "187.35.158.150", port: 38872}, {ip: "187.44.1.167", port: 8080}, {ip: "187.45.127.87", port: 20183}, {ip: "187.45.156.109", port: 8080}, {ip: "187.5.218.215", port: 20183}, {ip: "187.58.65.225", port: 3128}, {ip: "187.63.111.37", port: 3128}, {ip: "187.72.166.10", port: 8080}, {ip: "187.73.68.14", port: 53281}, {ip: "187.84.177.6", port: 45903}, {ip: "187.84.191.170", port: 43936}, {ip: "187.87.204.210", port: 45597}, {ip: "187.87.39.247", port: 31793}, {ip: "189.1.16.162", port: 23500}, {ip: "189.113.124.162", port: 8080}, {ip: "189.124.195.185", port: 37318}, {ip: "189.3.196.18", port: 61595}, {ip: "189.37.33.59", port: 35532}, {ip: "189.7.49.66", port: 42700}, {ip: "189.90.194.35", port: 30843}, {ip: "189.90.248.75", port: 8080}, {ip: "189.91.231.43", port: 3128}, {ip: "191.239.243.156", port: 3128}, {ip: "191.240.154.246", port: 23500}, {ip: "191.240.156.154", port: 36127}, {ip: "191.240.99.142", port: 9090}, {ip: "191.241.226.230", port: 53281}, {ip: "191.241.228.74", port: 20183}, {ip: "191.241.228.78", port: 20183}, {ip: "191.241.33.238", port: 39188}, {ip: "191.241.36.170", port: 8080}, {ip: "191.241.36.218", port: 3128}, {ip: "191.242.182.132", port: 8081}, {ip: "191.243.221.130", port: 3128}, {ip: "191.255.207.231", port: 20183}, {ip: "191.36.192.196", port: 3128}, {ip: "191.36.244.230", port: 51377}, {ip: "191.5.0.79", port: 53281}, {ip: "191.6.228.6", port: 53281}, {ip: "191.7.193.18", port: 38133}, {ip: "191.7.20.134", port: 3128}, {ip: "192.140.91.173", port: 20183}, {ip: "200.150.86.138", port: 44677}, {ip: "200.155.36.185", port: 3128}, {ip: "200.155.36.188", port: 3128}, {ip: "200.155.39.41", port: 3128}, {ip: "200.174.158.26", port: 34112}, {ip: "200.187.177.105", port: 20183}, {ip: "200.187.87.138", port: 20183}, {ip: "200.192.252.201", port: 8080}, {ip: "200.192.255.102", port: 8080}, {ip: "200.203.144.2", port: 50262}, {ip: "200.229.238.42", port: 20183}, {ip: "200.233.134.85", port: 43172}, {ip: "200.233.136.177", port: 20183}, {ip: "200.241.44.3", port: 20183}, {ip: "200.255.122.170", port: 8080}, {ip: "200.255.122.174", port: 8080}, {ip: "201.12.21.57", port: 8080}, {ip: "201.131.224.21", port: 56200}, {ip: "201.182.223.16", port: 37492}, {ip: "201.20.89.126", port: 8080}, {ip: "201.22.95.10", port: 8080}, {ip: "201.57.167.34", port: 8080}, {ip: "201.59.200.246", port: 80}, {ip: "201.6.167.178", port: 3128}, {ip: "201.90.36.194", port: 3128}, {ip: "45.226.20.6", port: 8080}, {ip: "45.234.139.129", port: 20183}, {ip: "45.234.200.18", port: 53281}, {ip: "45.235.87.4", port: 51996}, {ip: "45.6.136.38", port: 53281}, {ip: "45.6.80.131", port: 52080}, {ip: "45.6.93.10", port: 8080}, {ip: "45.71.108.162", port: 53281}], + "PK" => [{ip: "103.18.243.154", port: 8080}, {ip: "110.36.218.126", port: 36651}, {ip: "110.36.234.210", port: 8080}, {ip: "110.39.162.74", port: 53281}, {ip: "110.39.174.58", port: 8080}, {ip: "111.68.108.34", port: 8080}, {ip: "125.209.116.182", port: 31653}, {ip: "125.209.78.21", port: 8080}, {ip: "125.209.82.78", port: 35087}, {ip: "180.92.156.150", port: 8080}, {ip: "202.142.158.114", port: 8080}, {ip: "202.147.173.10", port: 8080}, {ip: "202.147.173.10", port: 80}, {ip: "202.69.38.82", port: 8080}, {ip: "203.128.16.126", port: 59538}, {ip: "203.128.16.154", port: 33002}, {ip: "27.255.4.170", port: 8080}], + "ID" => [{ip: "101.128.68.113", port: 8080}, {ip: "101.255.116.113", port: 53281}, {ip: "101.255.120.170", port: 6969}, {ip: "101.255.121.74", port: 8080}, {ip: "101.255.124.242", port: 8080}, {ip: "101.255.124.242", port: 80}, {ip: "101.255.56.138", port: 53560}, {ip: "103.10.171.132", port: 41043}, {ip: "103.10.81.172", port: 80}, {ip: "103.108.158.3", port: 48196}, {ip: "103.111.219.159", port: 53281}, {ip: "103.111.54.26", port: 49781}, {ip: "103.111.54.74", port: 8080}, {ip: "103.19.110.177", port: 8080}, {ip: "103.2.146.66", port: 49089}, {ip: "103.206.168.177", port: 53281}, {ip: "103.206.253.58", port: 49573}, {ip: "103.21.92.254", port: 33929}, {ip: "103.226.49.83", port: 23500}, {ip: "103.227.147.142", port: 37581}, {ip: "103.23.101.58", port: 8080}, {ip: "103.24.107.2", port: 8181}, {ip: "103.245.19.222", port: 53281}, {ip: "103.247.122.38", port: 8080}, {ip: "103.247.218.166", port: 3128}, {ip: "103.248.219.26", port: 53634}, {ip: "103.253.2.165", port: 33543}, {ip: "103.253.2.168", port: 51229}, {ip: "103.253.2.174", port: 30827}, {ip: "103.28.114.134", port: 8080}, {ip: "103.28.220.73", port: 53281}, {ip: "103.30.246.47", port: 3128}, {ip: "103.31.45.169", port: 57655}, {ip: "103.41.122.14", port: 53281}, {ip: "103.75.101.97", port: 8080}, {ip: "103.76.17.151", port: 23500}, {ip: "103.76.50.181", port: 8080}, {ip: "103.76.50.181", port: 80}, {ip: "103.76.50.182", port: 8080}, {ip: "103.78.74.170", port: 3128}, {ip: "103.78.80.194", port: 33442}, {ip: "103.8.122.5", port: 53297}, {ip: "103.80.236.107", port: 53281}, {ip: "103.80.238.203", port: 53281}, {ip: "103.86.140.74", port: 59538}, {ip: "103.94.122.254", port: 8080}, {ip: "103.94.125.244", port: 41508}, {ip: "103.94.169.19", port: 8080}, {ip: "103.94.7.254", port: 53281}, {ip: "106.0.51.50", port: 17385}, {ip: "110.93.13.202", port: 34881}, {ip: "112.78.37.6", port: 54791}, {ip: "114.199.110.58", port: 55898}, {ip: "114.199.112.170", port: 23500}, {ip: "114.199.123.194", port: 8080}, {ip: "114.57.33.162", port: 46935}, {ip: "114.57.33.214", port: 8080}, {ip: "114.6.197.254", port: 8080}, {ip: "114.7.15.146", port: 8080}, {ip: "114.7.162.254", port: 53281}, {ip: "115.124.75.226", port: 53990}, {ip: "115.124.75.228", port: 3128}, {ip: "117.102.78.42", port: 8080}, {ip: "117.102.93.251", port: 8080}, {ip: "117.102.94.186", port: 8080}, {ip: "117.102.94.186", port: 80}, {ip: "117.103.2.249", port: 58276}, {ip: "117.54.13.174", port: 34190}, {ip: "117.74.124.129", port: 8088}, {ip: "118.97.100.83", port: 35220}, {ip: "118.97.191.162", port: 80}, {ip: "118.97.191.203", port: 8080}, {ip: "118.97.36.18", port: 8080}, {ip: "118.97.73.85", port: 53281}, {ip: "118.99.105.226", port: 8080}, {ip: "119.252.168.53", port: 53281}, {ip: "122.248.45.35", port: 53281}, {ip: "122.50.6.186", port: 8080}, {ip: "122.50.6.186", port: 80}, {ip: "123.231.226.114", port: 47562}, {ip: "123.255.202.83", port: 32523}, {ip: "124.158.164.195", port: 8080}, {ip: "124.81.99.30", port: 3128}, {ip: "137.59.162.10", port: 3128}, {ip: "139.0.29.20", port: 59532}, {ip: "139.255.123.194", port: 4550}, {ip: "139.255.16.171", port: 31773}, {ip: "139.255.17.2", port: 47421}, {ip: "139.255.19.162", port: 42371}, {ip: "139.255.7.81", port: 53281}, {ip: "139.255.91.115", port: 8080}, {ip: "139.255.92.26", port: 53281}, {ip: "158.140.181.140", port: 54041}, {ip: "160.202.40.20", port: 55655}, {ip: "175.103.42.147", port: 8080}, {ip: "180.178.98.198", port: 8080}, {ip: "180.250.101.146", port: 8080}, {ip: "182.23.107.212", port: 3128}, {ip: "182.23.2.101", port: 49833}, {ip: "182.23.7.226", port: 8080}, {ip: "182.253.209.203", port: 3128}, {ip: "183.91.66.210", port: 80}, {ip: "202.137.10.179", port: 57338}, {ip: "202.137.25.53", port: 3128}, {ip: "202.137.25.8", port: 8080}, {ip: "202.138.242.76", port: 4550}, {ip: "202.138.249.202", port: 43108}, {ip: "202.148.2.254", port: 8000}, {ip: "202.162.201.94", port: 53281}, {ip: "202.165.47.26", port: 8080}, {ip: "202.43.167.130", port: 8080}, {ip: "202.51.126.10", port: 53281}, {ip: "202.59.171.164", port: 58567}, {ip: "202.93.128.98", port: 3128}, {ip: "203.142.72.114", port: 808}, {ip: "203.153.117.65", port: 54144}, {ip: "203.189.89.1", port: 53281}, {ip: "203.77.239.18", port: 37002}, {ip: "203.99.123.25", port: 61502}, {ip: "220.247.168.163", port: 53281}, {ip: "220.247.173.154", port: 53281}, {ip: "220.247.174.206", port: 53445}, {ip: "222.124.131.211", port: 47343}, {ip: "222.124.173.146", port: 53281}, {ip: "222.124.2.131", port: 8080}, {ip: "222.124.2.186", port: 8080}, {ip: "222.124.215.187", port: 38913}, {ip: "222.124.221.179", port: 53281}, {ip: "223.25.101.242", port: 59504}, {ip: "223.25.97.62", port: 8080}, {ip: "223.25.99.38", port: 80}, {ip: "27.111.44.202", port: 80}, {ip: "27.111.47.3", port: 51144}, {ip: "36.37.124.234", port: 36179}, {ip: "36.37.124.235", port: 36179}, {ip: "36.37.81.135", port: 8080}, {ip: "36.37.89.98", port: 32323}, {ip: "36.66.217.179", port: 8080}, {ip: "36.66.98.6", port: 53281}, {ip: "36.67.143.183", port: 48746}, {ip: "36.67.206.187", port: 8080}, {ip: "36.67.32.87", port: 8080}, {ip: "36.67.93.220", port: 3128}, {ip: "36.67.93.220", port: 80}, {ip: "36.89.10.51", port: 34115}, {ip: "36.89.119.149", port: 8080}, {ip: "36.89.157.23", port: 37728}, {ip: "36.89.181.155", port: 60165}, {ip: "36.89.188.11", port: 39507}, {ip: "36.89.194.113", port: 37811}, {ip: "36.89.226.254", port: 8081}, {ip: "36.89.232.138", port: 23500}, {ip: "36.89.39.10", port: 3128}, {ip: "36.89.65.253", port: 60997}, {ip: "43.243.141.114", port: 8080}, {ip: "43.245.184.202", port: 41102}, {ip: "43.245.184.238", port: 80}, {ip: "66.96.233.225", port: 35053}, {ip: "66.96.237.253", port: 8080}], + "BD" => [{ip: "103.103.88.91", port: 8080}, {ip: "103.106.119.154", port: 8080}, {ip: "103.106.236.1", port: 8080}, {ip: "103.106.236.41", port: 8080}, {ip: "103.108.144.139", port: 53281}, {ip: "103.109.57.218", port: 8080}, {ip: "103.109.58.242", port: 8080}, {ip: "103.112.129.106", port: 31094}, {ip: "103.112.129.82", port: 53281}, {ip: "103.114.10.177", port: 8080}, {ip: "103.114.10.250", port: 8080}, {ip: "103.15.245.26", port: 8080}, {ip: "103.195.204.73", port: 21776}, {ip: "103.197.49.106", port: 49688}, {ip: "103.198.168.29", port: 21776}, {ip: "103.214.200.6", port: 59008}, {ip: "103.218.25.161", port: 8080}, {ip: "103.218.25.41", port: 8080}, {ip: "103.218.26.204", port: 8080}, {ip: "103.218.27.221", port: 8080}, {ip: "103.231.229.90", port: 53281}, {ip: "103.239.252.233", port: 8080}, {ip: "103.239.252.50", port: 8080}, {ip: "103.239.253.193", port: 8080}, {ip: "103.250.68.193", port: 51370}, {ip: "103.5.232.146", port: 8080}, {ip: "103.73.224.53", port: 23500}, {ip: "103.9.134.73", port: 65301}, {ip: "113.11.47.242", port: 40071}, {ip: "113.11.5.67", port: 40071}, {ip: "114.31.5.34", port: 52606}, {ip: "115.127.51.226", port: 42764}, {ip: "115.127.64.62", port: 39611}, {ip: "115.127.91.106", port: 8080}, {ip: "119.40.85.198", port: 36899}, {ip: "123.200.29.110", port: 23500}, {ip: "123.49.51.42", port: 55124}, {ip: "163.47.36.90", port: 3128}, {ip: "180.211.134.158", port: 23500}, {ip: "180.211.193.74", port: 40536}, {ip: "180.92.238.226", port: 53451}, {ip: "182.160.104.213", port: 8080}, {ip: "202.191.126.58", port: 23500}, {ip: "202.4.126.170", port: 8080}, {ip: "202.5.37.241", port: 33623}, {ip: "202.5.57.5", port: 61729}, {ip: "202.79.17.65", port: 60122}, {ip: "203.188.248.52", port: 23500}, {ip: "27.147.146.78", port: 52220}, {ip: "27.147.164.10", port: 52344}, {ip: "27.147.212.38", port: 53281}, {ip: "27.147.217.154", port: 43252}, {ip: "27.147.219.102", port: 49464}, {ip: "43.239.74.137", port: 8080}, {ip: "43.240.103.252", port: 8080}, {ip: "45.125.223.57", port: 8080}, {ip: "45.125.223.81", port: 8080}, {ip: "45.251.228.122", port: 41418}, {ip: "45.64.132.137", port: 8080}, {ip: "45.64.132.137", port: 80}, {ip: "61.247.186.137", port: 8080}], + "MX" => [{ip: "148.217.94.54", port: 3128}, {ip: "177.244.28.77", port: 53281}, {ip: "187.141.73.147", port: 53281}, {ip: "187.185.15.35", port: 53281}, {ip: "187.188.46.172", port: 53455}, {ip: "187.216.83.185", port: 8080}, {ip: "187.216.90.46", port: 53281}, {ip: "187.243.253.182", port: 33796}, {ip: "189.195.132.86", port: 43286}, {ip: "189.204.158.161", port: 8080}, {ip: "200.79.180.115", port: 8080}, {ip: "201.140.113.90", port: 37193}, {ip: "201.144.14.229", port: 53281}, {ip: "201.163.73.93", port: 53281}], + "PH" => [{ip: "103.86.187.242", port: 23500}, {ip: "122.54.101.69", port: 8080}, {ip: "122.54.65.150", port: 8080}, {ip: "125.5.20.134", port: 53281}, {ip: "146.88.77.51", port: 8080}, {ip: "182.18.200.92", port: 8080}, {ip: "219.90.87.91", port: 53281}, {ip: "58.69.12.210", port: 8080}], + "EG" => [{ip: "41.65.0.167", port: 8080}], + "VN" => [{ip: "1.55.240.156", port: 53281}, {ip: "101.99.23.136", port: 3128}, {ip: "103.15.51.160", port: 8080}, {ip: "113.161.128.169", port: 60427}, {ip: "113.161.161.143", port: 57967}, {ip: "113.161.173.10", port: 3128}, {ip: "113.161.35.108", port: 30028}, {ip: "113.164.79.177", port: 46281}, {ip: "113.190.235.50", port: 34619}, {ip: "115.78.160.247", port: 8080}, {ip: "117.2.155.29", port: 47228}, {ip: "117.2.17.26", port: 53281}, {ip: "117.2.22.41", port: 41973}, {ip: "117.4.145.16", port: 51487}, {ip: "118.69.219.185", port: 55184}, {ip: "118.69.61.212", port: 53281}, {ip: "118.70.116.227", port: 61651}, {ip: "118.70.219.124", port: 53281}, {ip: "221.121.12.238", port: 36077}, {ip: "27.2.7.59", port: 52148}], + "CD" => [{ip: "41.79.233.45", port: 8080}], + "TR" => [{ip: "151.80.65.175", port: 3128}, {ip: "176.235.186.242", port: 37043}, {ip: "178.250.92.18", port: 8080}, {ip: "185.203.170.92", port: 8080}, {ip: "185.203.170.94", port: 8080}, {ip: "185.203.170.95", port: 8080}, {ip: "185.51.36.152", port: 41258}, {ip: "195.137.223.50", port: 41336}, {ip: "195.155.98.70", port: 52598}, {ip: "212.156.146.22", port: 40080}, {ip: "213.14.31.122", port: 44621}, {ip: "31.145.137.139", port: 31871}, {ip: "31.145.138.129", port: 31871}, {ip: "31.145.138.146", port: 34159}, {ip: "31.145.187.172", port: 30636}, {ip: "78.188.4.124", port: 34514}, {ip: "88.248.23.216", port: 36426}, {ip: "93.182.72.36", port: 8080}, {ip: "95.0.194.241", port: 9090}], +} diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr new file mode 100644 index 00000000..b3815f6a --- /dev/null +++ b/src/invidious/yt_backend/youtube_api.cr @@ -0,0 +1,447 @@ +# +# This file contains youtube API wrappers +# + +module YoutubeAPI + extend self + + # Enumerate used to select one of the clients supported by the API + enum ClientType + Web + WebEmbeddedPlayer + WebMobile + WebScreenEmbed + Android + AndroidEmbeddedPlayer + AndroidScreenEmbed + end + + # List of hard-coded values used by the different clients + HARDCODED_CLIENTS = { + ClientType::Web => { + name: "WEB", + version: "2.20210721.00.00", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "WATCH_FULL_SCREEN", + }, + ClientType::WebEmbeddedPlayer => { + name: "WEB_EMBEDDED_PLAYER", # 56 + version: "1.20210721.1.0", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "EMBED", + }, + ClientType::WebMobile => { + name: "MWEB", + version: "2.20210726.08.00", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "", # None + }, + ClientType::WebScreenEmbed => { + name: "WEB", + version: "2.20210721.00.00", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "EMBED", + }, + ClientType::Android => { + name: "ANDROID", + version: "16.20", + api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", + screen: "", # ?? + }, + ClientType::AndroidEmbeddedPlayer => { + name: "ANDROID_EMBEDDED_PLAYER", # 55 + version: "16.20", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "", # None? + }, + ClientType::AndroidScreenEmbed => { + name: "ANDROID", # 3 + version: "16.20", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "EMBED", + }, + } + + #################################################################### + # struct ClientConfig + # + # Data structure used to pass a client configuration to the different + # API endpoints handlers. + # + # Use case examples: + # + # ``` + # # Get Norwegian search results + # conf_1 = ClientConfig.new(region: "NO") + # YoutubeAPI::search("Kollektivet", params: "", client_config: conf_1) + # + # # Use the Android client to request video streams URLs + # conf_2 = ClientConfig.new(client_type: ClientType::Android) + # YoutubeAPI::player(video_id: "dQw4w9WgXcQ", client_config: conf_2) + # + # # Proxy request through russian proxies + # conf_3 = ClientConfig.new(proxy_region: "RU") + # YoutubeAPI::next({video_id: "dQw4w9WgXcQ"}, client_config: conf_3) + # ``` + # + struct ClientConfig + # Type of client to emulate. + # See `enum ClientType` and `HARDCODED_CLIENTS`. + property client_type : ClientType + + # Region to provide to youtube, e.g to alter search results + # (this is passed as the `gl` parmeter). + property region : String | Nil + + # ISO code of country where the proxy is located. + # Used in case of geo-restricted videos. + property proxy_region : String | Nil + + # Initialization function + def initialize( + *, + @client_type = ClientType::Web, + @region = "US", + @proxy_region = nil + ) + end + + # Getter functions that provides easy access to hardcoded clients + # parameters (name/version strings and related API key) + def name : String + HARDCODED_CLIENTS[@client_type][:name] + end + + # :ditto: + def version : String + HARDCODED_CLIENTS[@client_type][:version] + end + + # :ditto: + def api_key : String + HARDCODED_CLIENTS[@client_type][:api_key] + end + + # :ditto: + def screen : String + HARDCODED_CLIENTS[@client_type][:screen] + end + + # Convert to string, for logging purposes + def to_s + return { + client_type: self.name, + region: @region, + proxy_region: @proxy_region, + }.to_s + end + end + + # Default client config, used if nothing is passed + DEFAULT_CLIENT_CONFIG = ClientConfig.new + + #################################################################### + # make_context(client_config) + # + # Return, as a Hash, the "context" data required to request the + # youtube API endpoints. + # + private def make_context(client_config : ClientConfig | Nil) : Hash + # Use the default client config if nil is passed + client_config ||= DEFAULT_CLIENT_CONFIG + + client_context = { + "client" => { + "hl" => "en", + "gl" => client_config.region || "US", # Can't be empty! + "clientName" => client_config.name, + "clientVersion" => client_config.version, + }, + } + + # Add some more context if it exists in the client definitions + if !client_config.screen.empty? + client_context["client"]["clientScreen"] = client_config.screen + end + + if client_config.screen == "EMBED" + client_context["thirdParty"] = { + "embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ", + } + end + + return client_context + end + + #################################################################### + # browse(continuation, client_config?) + # browse(browse_id, params, client_config?) + # + # Requests the youtubei/v1/browse endpoint with the required headers + # and POST data in order to get a JSON reply in english that can + # be easily parsed. + # + # Both forms can take an optional ClientConfig parameter (see + # `struct ClientConfig` above for more details). + # + # The requested data can either be: + # + # - A continuation token (ctoken). Depending on this token's + # contents, the returned data can be playlist videos, channel + # community tab content, channel info, ... + # + # - A playlist ID (parameters MUST be an empty string) + # + def browse(continuation : String, client_config : ClientConfig | Nil = nil) + # JSON Request data, required by the API + data = { + "context" => self.make_context(client_config), + "continuation" => continuation, + } + + return self._post_json("/youtubei/v1/browse", data, client_config) + end + + # :ditto: + def browse( + browse_id : String, + *, # Force the following paramters to be passed by name + params : String, + client_config : ClientConfig | Nil = nil + ) + # JSON Request data, required by the API + data = { + "browseId" => browse_id, + "context" => self.make_context(client_config), + } + + # Append the additionnal parameters if those were provided + # (this is required for channel info, playlist and community, e.g) + if params != "" + data["params"] = params + end + + return self._post_json("/youtubei/v1/browse", data, client_config) + end + + #################################################################### + # next(continuation, client_config?) + # next(data, client_config?) + # + # Requests the youtubei/v1/next endpoint with the required headers + # and POST data in order to get a JSON reply in english that can + # be easily parsed. + # + # Both forms can take an optional ClientConfig parameter (see + # `struct ClientConfig` above for more details). + # + # The requested data can be: + # + # - A continuation token (ctoken). Depending on this token's + # contents, the returned data can be videos comments, + # their replies, ... In this case, the string must be passed + # directly to the function. E.g: + # + # ``` + # YoutubeAPI::next("ABCDEFGH_abcdefgh==") + # ``` + # + # - Arbitrary parameters, in Hash form. See examples below for + # known examples of arbitrary data that can be passed to YouTube: + # + # ``` + # # Get the videos related to a specific video ID + # YoutubeAPI::next({"videoId" => "dQw4w9WgXcQ"}) + # + # # Get a playlist video's details + # YoutubeAPI::next({ + # "videoId" => "9bZkp7q19f0", + # "playlistId" => "PL_oFlvgqkrjUVQwiiE3F3k3voF4tjXeP0", + # }) + # ``` + # + def next(continuation : String, *, client_config : ClientConfig | Nil = nil) + # JSON Request data, required by the API + data = { + "context" => self.make_context(client_config), + "continuation" => continuation, + } + + return self._post_json("/youtubei/v1/next", data, client_config) + end + + # :ditto: + def next(data : Hash, *, client_config : ClientConfig | Nil = nil) + # JSON Request data, required by the API + data2 = data.merge({ + "context" => self.make_context(client_config), + }) + + return self._post_json("/youtubei/v1/next", data2, client_config) + end + + # Allow a NamedTuple to be passed, too. + def next(data : NamedTuple, *, client_config : ClientConfig | Nil = nil) + return self.next(data.to_h, client_config: client_config) + end + + #################################################################### + # player(video_id, params, client_config?) + # + # Requests the youtubei/v1/player endpoint with the required headers + # and POST data in order to get a JSON reply. + # + # The requested data is a video ID (`v=` parameter), with some + # additional paramters, formatted as a base64 string. + # + # An optional ClientConfig parameter can be passed, too (see + # `struct ClientConfig` above for more details). + # + def player( + video_id : String, + *, # Force the following paramters to be passed by name + params : String, + client_config : ClientConfig | Nil = nil + ) + # JSON Request data, required by the API + data = { + "videoId" => video_id, + "context" => self.make_context(client_config), + } + + # Append the additionnal parameters if those were provided + if params != "" + data["params"] = params + end + + return self._post_json("/youtubei/v1/player", data, client_config) + end + + #################################################################### + # resolve_url(url, client_config?) + # + # Requests the youtubei/v1/navigation/resolve_url endpoint with the + # required headers and POST data in order to get a JSON reply. + # + # An optional ClientConfig parameter can be passed, too (see + # `struct ClientConfig` above for more details). + # + # Output: + # + # ``` + # # Valid channel "brand URL" gives the related UCID and browse ID + # channel_a = YoutubeAPI.resolve_url("https://youtube.com/c/google") + # channel_a # => { + # "endpoint": { + # "browseEndpoint": { + # "params": "EgC4AQA%3D", + # "browseId":"UCK8sQmJBp8GCxrOtXWBpyEA" + # }, + # ... + # } + # } + # + # # Invalid URL returns throws an InfoException + # channel_b = YoutubeAPI.resolve_url("https://youtube.com/c/invalid") + # ``` + # + def resolve_url(url : String, client_config : ClientConfig | Nil = nil) + data = { + "context" => self.make_context(nil), + "url" => url, + } + + return self._post_json("/youtubei/v1/navigation/resolve_url", data, client_config) + end + + #################################################################### + # search(search_query, params, client_config?) + # + # Requests the youtubei/v1/search endpoint with the required headers + # and POST data in order to get a JSON reply. As the search results + # vary depending on the region, a region code can be specified in + # order to get non-US results. + # + # The requested data is a search string, with some additional + # paramters, formatted as a base64 string. + # + # An optional ClientConfig parameter can be passed, too (see + # `struct ClientConfig` above for more details). + # + def search( + search_query : String, + params : String, + client_config : ClientConfig | Nil = nil + ) + # JSON Request data, required by the API + data = { + "query" => search_query, + "context" => self.make_context(client_config), + "params" => params, + } + + return self._post_json("/youtubei/v1/search", data, client_config) + end + + #################################################################### + # _post_json(endpoint, data, client_config?) + # + # Internal function that does the actual request to youtube servers + # and handles errors. + # + # The requested data is an endpoint (URL without the domain part) + # and the data as a Hash object. + # + def _post_json( + endpoint : String, + data : Hash, + client_config : ClientConfig | Nil + ) : Hash(String, JSON::Any) + # Use the default client config if nil is passed + client_config ||= DEFAULT_CLIENT_CONFIG + + # Query parameters + url = "#{endpoint}?key=#{client_config.api_key}" + + headers = HTTP::Headers{ + "Content-Type" => "application/json; charset=UTF-8", + "Accept-Encoding" => "gzip", + } + + # Logging + LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") + LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config.to_s}") + LOGGER.trace("YoutubeAPI: POST data: #{data.to_s}") + + # Send the POST request + if client_config.proxy_region + response = YT_POOL.client( + client_config.proxy_region, + &.post(url, headers: headers, body: data.to_json) + ) + else + response = YT_POOL.client &.post( + url, headers: headers, body: data.to_json + ) + end + + # Convert result to Hash + initial_data = JSON.parse(response.body).as_h + + # Error handling + if initial_data.has_key?("error") + code = initial_data["error"]["code"] + message = initial_data["error"]["message"].to_s.sub(/(\\n)+\^$/, "") + + # Logging + LOGGER.error("YoutubeAPI: Got error #{code} when requesting #{endpoint}") + LOGGER.error("YoutubeAPI: #{message}") + LOGGER.info("YoutubeAPI: POST data was: #{data.to_s}") + + raise InfoException.new("Could not extract JSON. Youtube API returned \ + error #{code} with message:
    \"#{message}\"") + end + + return initial_data + end +end # End of module -- cgit v1.2.3 From d300797e229e12973559334cc53a17f79a27ac90 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Oct 2021 22:17:24 +0200 Subject: Move the YoutubeConnectionPool code to its own file --- src/invidious/helpers/utils.cr | 81 ----------------------------- src/invidious/yt_backend/connection_pool.cr | 81 +++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 81 deletions(-) create mode 100644 src/invidious/yt_backend/connection_pool.cr (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 68ba76f9..6100d403 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -1,4 +1,3 @@ -require "lsquic" require "db" def add_yt_headers(request) @@ -16,55 +15,6 @@ def add_yt_headers(request) end end -struct YoutubeConnectionPool - property! url : URI - property! capacity : Int32 - property! timeout : Float64 - property pool : DB::Pool(QUIC::Client | HTTP::Client) - - def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true) - @url = url - @pool = build_pool(use_quic) - end - - def client(region = nil, &block) - if region - conn = make_client(url, region) - response = yield conn - else - conn = pool.checkout - begin - response = yield conn - rescue ex - conn.close - conn = QUIC::Client.new(url) - conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET - conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC - conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - response = yield conn - ensure - pool.release(conn) - end - end - - response - end - - private def build_pool(use_quic) - DB::Pool(QUIC::Client | HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do - if use_quic - conn = QUIC::Client.new(url) - else - conn = HTTP::Client.new(url) - end - conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET - conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC - conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - conn - end - end -end - # See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html def ci_lower_bound(pos, n) if n == 0 @@ -85,37 +35,6 @@ def elapsed_text(elapsed) "#{(millis * 1000).round(2)}µs" end -def make_client(url : URI, region = nil) - # TODO: Migrate any applicable endpoints to QUIC - client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure) - client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC - client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" - client.read_timeout = 10.seconds - client.connect_timeout = 10.seconds - - if region - PROXY_LIST[region]?.try &.sample(40).each do |proxy| - begin - proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) - client.set_proxy(proxy) - break - rescue ex - end - end - end - - return client -end - -def make_client(url : URI, region = nil, &block) - client = make_client(url, region) - begin - yield client - ensure - client.close - end -end - def decode_length_seconds(string) length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i length_seconds = [0] * (3 - length_seconds.size) + length_seconds diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr new file mode 100644 index 00000000..505f2cf6 --- /dev/null +++ b/src/invidious/yt_backend/connection_pool.cr @@ -0,0 +1,81 @@ +require "lsquic" + +struct YoutubeConnectionPool + property! url : URI + property! capacity : Int32 + property! timeout : Float64 + property pool : DB::Pool(QUIC::Client | HTTP::Client) + + def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true) + @url = url + @pool = build_pool(use_quic) + end + + def client(region = nil, &block) + if region + conn = make_client(url, region) + response = yield conn + else + conn = pool.checkout + begin + response = yield conn + rescue ex + conn.close + conn = QUIC::Client.new(url) + conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET + conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC + conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + response = yield conn + ensure + pool.release(conn) + end + end + + response + end + + private def build_pool(use_quic) + DB::Pool(QUIC::Client | HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do + if use_quic + conn = QUIC::Client.new(url) + else + conn = HTTP::Client.new(url) + end + conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET + conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC + conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + conn + end + end +end + +def make_client(url : URI, region = nil) + # TODO: Migrate any applicable endpoints to QUIC + client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure) + client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC + client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + client.read_timeout = 10.seconds + client.connect_timeout = 10.seconds + + if region + PROXY_LIST[region]?.try &.sample(40).each do |proxy| + begin + proxy = HTTPProxy.new(proxy_host: proxy[:ip], proxy_port: proxy[:port]) + client.set_proxy(proxy) + break + rescue ex + end + end + end + + return client +end + +def make_client(url : URI, region = nil, &block) + client = make_client(url, region) + begin + yield client + ensure + client.close + end +end -- cgit v1.2.3 From f7f09109531979de6e8bc7789b56b3e69b818b6b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Oct 2021 22:21:40 +0200 Subject: Remove fetch_continuation_token(): dead code --- src/invidious/helpers/helpers.cr | 18 ------------------ 1 file changed, 18 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 968062d6..baf82740 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -270,24 +270,6 @@ def extract_selected_tab(tabs) return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"] end -def fetch_continuation_token(items : Array(JSON::Any)) - # Fetches the continuation token from an array of items - return items.last["continuationItemRenderer"]? - .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s -end - -def fetch_continuation_token(initial_data : Hash(String, JSON::Any)) - # Fetches the continuation token from initial data - if initial_data["onResponseReceivedActions"]? - continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"] - else - tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]) - continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"] - end - - return fetch_continuation_token(continuation_items.as_a) -end - def check_enum(db, enum_name, struct_type = nil) return # TODO -- cgit v1.2.3 From 7df2fd0bc8f5174ab0c428165ec0a4202dcf1fd5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Oct 2021 22:32:04 +0200 Subject: Add 'require' statement to 'invidious.cr' --- src/invidious.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 18ec0b97..3a20b0d8 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -27,6 +27,7 @@ require "yaml" require "compress/zip" require "protodec/utils" require "./invidious/helpers/*" +require "./invidious/yt_backend/*" require "./invidious/*" require "./invidious/channels/*" require "./invidious/routes/**" -- cgit v1.2.3 From 8805ee7c8c5d1023c032b52cc79b1c1048b60afd Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 11 Oct 2021 18:55:15 +0200 Subject: Add fetch_continuation_token back (required by #2215) --- src/invidious/yt_backend/extractors_utils.cr | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/invidious/yt_backend/extractors_utils.cr (limited to 'src') diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr new file mode 100644 index 00000000..e0a13031 --- /dev/null +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -0,0 +1,17 @@ +def fetch_continuation_token(items : Array(JSON::Any)) + # Fetches the continuation token from an array of items + return items.last["continuationItemRenderer"]? + .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s +end + +def fetch_continuation_token(initial_data : Hash(String, JSON::Any)) + # Fetches the continuation token from initial data + if initial_data["onResponseReceivedActions"]? + continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"] + else + tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]) + continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"] + end + + return fetch_continuation_token(continuation_items.as_a) +end -- cgit v1.2.3 From e17c8b1f4deaa56dcdd5d3b9f62bec13f9b71dc7 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 11 Oct 2021 18:58:12 +0200 Subject: Move 'extract_videos' and 'extract_selected_tab' too --- src/invidious/helpers/helpers.cr | 19 ------------------- src/invidious/yt_backend/extractors_utils.cr | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index baf82740..c01ca11e 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -251,25 +251,6 @@ def html_to_content(description_html : String) return description end -def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) - extracted = extract_items(initial_data, author_fallback, author_id_fallback) - - target = [] of SearchItem - extracted.each do |i| - if i.is_a?(Category) - i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video } - else - target << i - end - end - return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) -end - -def extract_selected_tab(tabs) - # Extract the selected tab from the array of tabs Youtube returns - return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"] -end - def check_enum(db, enum_name, struct_type = nil) return # TODO diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index e0a13031..b76fa09a 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -1,3 +1,22 @@ +def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) + extracted = extract_items(initial_data, author_fallback, author_id_fallback) + + target = [] of SearchItem + extracted.each do |i| + if i.is_a?(Category) + i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video } + else + target << i + end + end + return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) +end + +def extract_selected_tab(tabs) + # Extract the selected tab from the array of tabs Youtube returns + return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"] +end + def fetch_continuation_token(items : Array(JSON::Any)) # Fetches the continuation token from an array of items return items.last["continuationItemRenderer"]? -- cgit v1.2.3 From 2571e420f3ebc93dd518ea7d97f03aeb6a00b2b8 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 12 Oct 2021 12:21:47 +0200 Subject: Move 'add_yt_headers()' to 'connection_pool.cr' --- src/invidious/helpers/utils.cr | 15 --------------- src/invidious/yt_backend/connection_pool.cr | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 6100d403..65067526 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -1,20 +1,5 @@ require "db" -def add_yt_headers(request) - request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" - request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" - request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" - request.headers["accept-language"] ||= "en-us,en;q=0.5" - return if request.resource.starts_with? "/sorry/index" - request.headers["x-youtube-client-name"] ||= "1" - request.headers["x-youtube-client-version"] ||= "2.20200609" - # Preserve original cookies and add new YT consent cookie for EU servers - request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" - if !CONFIG.cookies.empty? - request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" - end -end - # See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html def ci_lower_bound(pos, n) if n == 0 diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 505f2cf6..5ba2d73c 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -1,5 +1,20 @@ require "lsquic" +def add_yt_headers(request) + request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" + request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" + request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + request.headers["accept-language"] ||= "en-us,en;q=0.5" + return if request.resource.starts_with? "/sorry/index" + request.headers["x-youtube-client-name"] ||= "1" + request.headers["x-youtube-client-version"] ||= "2.20200609" + # Preserve original cookies and add new YT consent cookie for EU servers + request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" + if !CONFIG.cookies.empty? + request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" + end +end + struct YoutubeConnectionPool property! url : URI property! capacity : Int32 -- cgit v1.2.3 From 33d9be0ffb6bad28eb2f624aec4a7ba8f9a1795c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 18 Oct 2021 16:12:49 +0200 Subject: Move 'extractors.cr' to 'yt_backend' folder --- src/invidious/helpers/extractors.cr | 625 --------------------------------- src/invidious/yt_backend/extractors.cr | 625 +++++++++++++++++++++++++++++++++ 2 files changed, 625 insertions(+), 625 deletions(-) delete mode 100644 src/invidious/helpers/extractors.cr create mode 100644 src/invidious/yt_backend/extractors.cr (limited to 'src') diff --git a/src/invidious/helpers/extractors.cr b/src/invidious/helpers/extractors.cr deleted file mode 100644 index 0277d43b..00000000 --- a/src/invidious/helpers/extractors.cr +++ /dev/null @@ -1,625 +0,0 @@ -# This file contains helper methods to parse the Youtube API json data into -# neat little packages we can use - -# Tuple of Parsers/Extractors so we can easily cycle through them. -private ITEM_CONTAINER_EXTRACTOR = { - Extractors::YouTubeTabs, - Extractors::SearchResults, - Extractors::Continuation, -} - -private ITEM_PARSERS = { - Parsers::VideoRendererParser, - Parsers::ChannelRendererParser, - Parsers::GridPlaylistRendererParser, - Parsers::PlaylistRendererParser, - Parsers::CategoryRendererParser, -} - -record AuthorFallback, name : String, id : String - -# Namespace for logic relating to parsing InnerTube data into various datastructs. -# -# Each of the parsers in this namespace are accessed through the #process() method -# which validates the given data as applicable to itself. If it is applicable the given -# data is passed to the private `#parse()` method which returns a datastruct of the given -# type. Otherwise, nil is returned. -private module Parsers - # Parses a InnerTube videoRenderer into a SearchVideo. Returns nil when the given object isn't a videoRenderer - # - # A videoRenderer renders a video to click on within the YouTube and Invidious UI. It is **not** - # the watchable video itself. - # - # See specs for example. - # - # `videoRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. - # - module VideoRendererParser - def self.process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?) - return self.parse(item_contents, author_fallback) - end - end - - private def self.parse(item_contents, author_fallback) - video_id = item_contents["videoId"].as_s - title = extract_text(item_contents["title"]?) || "" - - # Extract author information - if author_info = item_contents.dig?("ownerText", "runs", 0) - author = author_info["text"].as_s - author_id = HelperExtractors.get_browse_id(author_info) - else - author = author_fallback.name - author_id = author_fallback.id - end - - # For live videos (and possibly recently premiered videos) there is no published information. - # Instead, in its place is the amount of people currently watching. This behavior should be replicated - # on Invidious once all features of livestreams are supported. On an unrelated note, defaulting to the current - # time for publishing isn't a good idea. - published = item_contents.dig?("publishedTimeText", "simpleText").try { |t| decode_date(t.as_s) } || Time.local - - # Typically views are stored under a "simpleText" in the "viewCountText". However, for - # livestreams and premiered it is stored under a "runs" array: [{"text":123}, {"text": "watching"}] - # When view count is disabled the "viewCountText" is not present on InnerTube data. - # TODO change default value to nil and typical encoding type to tuple storing type (watchers, views, etc) - # and count - view_count = item_contents.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 - description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" - - # The length information *should* only always exist in "lengthText". However, the legacy Invidious code - # extracts from "thumbnailOverlays" when it doesn't. More testing is needed to see if this is - # actually needed - if length_container = item_contents["lengthText"]? - length_seconds = decode_length_seconds(length_container["simpleText"].as_s) - elsif length_container = item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?) - # This needs to only go down the `simpleText` path (if possible). If more situations came up that requires - # a specific pathway then we should add an argument to extract_text that'll make this possible - length_seconds = length_container.dig?("thumbnailOverlayTimeStatusRenderer", "text", "simpleText") - - if length_seconds - length_seconds = decode_length_seconds(length_seconds.as_s) - else - length_seconds = 0 - end - else - length_seconds = 0 - end - - live_now = false - paid = false - premium = false - - premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } - - item_contents["badges"]?.try &.as_a.each do |badge| - b = badge["metadataBadgeRenderer"] - case b["label"].as_s - when "LIVE NOW" - live_now = true - when "New", "4K", "CC" - # TODO - when "Premium" - # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] - premium = true - else nil # Ignore - end - end - - SearchVideo.new({ - title: title, - id: video_id, - author: author, - ucid: author_id, - published: published, - views: view_count, - description_html: description_html, - length_seconds: length_seconds, - live_now: live_now, - premium: premium, - premiere_timestamp: premiere_timestamp, - }) - end - - def self.parser_name - return {{@type.name}} - end - end - - # Parses a InnerTube channelRenderer into a SearchChannel. Returns nil when the given object isn't a channelRenderer - # - # A channelRenderer renders a channel to click on within the YouTube and Invidious UI. It is **not** - # the channel page itself. - # - # See specs for example. - # - # `channelRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. - # - module ChannelRendererParser - def self.process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?) - return self.parse(item_contents, author_fallback) - end - end - - private def self.parse(item_contents, author_fallback) - author = extract_text(item_contents["title"]) || author_fallback.name - author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id - - author_thumbnail = HelperExtractors.get_thumbnails(item_contents) - # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. - # Always simpleText - # TODO change default value to nil - subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") - .try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 - - # Auto-generated channels doesn't have videoCountText - # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922 - auto_generated = item_contents["videoCountText"]?.nil? - - video_count = HelperExtractors.get_video_count(item_contents) - description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" - - SearchChannel.new({ - author: author, - ucid: author_id, - author_thumbnail: author_thumbnail, - subscriber_count: subscriber_count, - video_count: video_count, - description_html: description_html, - auto_generated: auto_generated, - }) - end - - def self.parser_name - return {{@type.name}} - end - end - - # Parses a InnerTube gridPlaylistRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridPlaylistRenderer - # - # A gridPlaylistRenderer renders a playlist, that is located in a grid, to click on within the YouTube and Invidious UI. - # It is **not** the playlist itself. - # - # See specs for example. - # - # `gridPlaylistRenderer`s can be found on the playlist-tabs of channels and expanded categories. - # - module GridPlaylistRendererParser - def self.process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = item["gridPlaylistRenderer"]? - return self.parse(item_contents, author_fallback) - end - end - - private def self.parse(item_contents, author_fallback) - title = extract_text(item_contents["title"]) || "" - plid = item_contents["playlistId"]?.try &.as_s || "" - - video_count = HelperExtractors.get_video_count(item_contents) - playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents) - - SearchPlaylist.new({ - title: title, - id: plid, - author: author_fallback.name, - ucid: author_fallback.id, - video_count: video_count, - videos: [] of SearchPlaylistVideo, - thumbnail: playlist_thumbnail, - }) - end - - def self.parser_name - return {{@type.name}} - end - end - - # Parses a InnerTube playlistRenderer into a SearchPlaylist. Returns nil when the given object isn't a playlistRenderer - # - # A playlistRenderer renders a playlist to click on within the YouTube and Invidious UI. It is **not** the playlist itself. - # - # See specs for example. - # - # `playlistRenderer`s can be found almost everywhere on YouTube. In categories, search results, recommended, etc. - # - module PlaylistRendererParser - def self.process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = item["playlistRenderer"]? - return self.parse(item_contents, author_fallback) - end - end - - private def self.parse(item_contents, author_fallback) - title = item_contents["title"]["simpleText"]?.try &.as_s || "" - plid = item_contents["playlistId"]?.try &.as_s || "" - - video_count = HelperExtractors.get_video_count(item_contents) - playlist_thumbnail = HelperExtractors.get_thumbnails_plural(item_contents) - - author_info = item_contents.dig?("shortBylineText", "runs", 0) - author = author_info.try &.["text"].as_s || author_fallback.name - author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id - - videos = item_contents["videos"]?.try &.as_a.map do |v| - v = v["childVideoRenderer"] - v_title = v.dig?("title", "simpleText").try &.as_s || "" - v_id = v["videoId"]?.try &.as_s || "" - v_length_seconds = v.dig?("lengthText", "simpleText").try { |t| decode_length_seconds(t.as_s) } || 0 - SearchPlaylistVideo.new({ - title: v_title, - id: v_id, - length_seconds: v_length_seconds, - }) - end || [] of SearchPlaylistVideo - - # TODO: item_contents["publishedTimeText"]? - - SearchPlaylist.new({ - title: title, - id: plid, - author: author, - ucid: author_id, - video_count: video_count, - videos: videos, - thumbnail: playlist_thumbnail, - }) - end - - def self.parser_name - return {{@type.name}} - end - end - - # Parses a InnerTube shelfRenderer into a Category. Returns nil when the given object isn't a shelfRenderer - # - # A shelfRenderer renders divided sections on YouTube. IE "People also watched" in search results and - # the various organizational sections in the channel home page. A separate one (richShelfRenderer) is used - # for YouTube home. A shelfRenderer can also sometimes be expanded to show more content within it. - # - # See specs for example. - # - # `shelfRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. - # - module CategoryRendererParser - def self.process(item : JSON::Any, author_fallback : AuthorFallback) - if item_contents = item["shelfRenderer"]? - return self.parse(item_contents, author_fallback) - end - end - - private def self.parse(item_contents, author_fallback) - title = extract_text(item_contents["title"]?) || "" - url = item_contents.dig?("endpoint", "commandMetadata", "webCommandMetadata", "url") - .try &.as_s - - # Sometimes a category can have badges. - badges = [] of Tuple(String, String) # (Badge style, label) - item_contents["badges"]?.try &.as_a.each do |badge| - badge = badge["metadataBadgeRenderer"] - badges << {badge["style"].as_s, badge["label"].as_s} - end - - # Category description - description_html = item_contents["subtitle"]?.try { |desc| parse_content(desc) } || "" - - # Content parsing - contents = [] of SearchItem - - # InnerTube recognizes some "special" categories, which are organized differently. - if special_category_container = item_contents["content"]? - if content_container = special_category_container["horizontalListRenderer"]? - elsif content_container = special_category_container["expandedShelfContentsRenderer"]? - elsif content_container = special_category_container["verticalListRenderer"]? - else - # Anything else, such as `horizontalMovieListRenderer` is currently unsupported. - return - end - else - # "Normal" category. - content_container = item_contents["contents"] - end - - raw_contents = content_container["items"]?.try &.as_a - if !raw_contents.nil? - raw_contents.each do |item| - result = extract_item(item) - if !result.nil? - contents << result - end - end - end - - Category.new({ - title: title, - contents: contents, - description_html: description_html, - url: url, - badges: badges, - }) - end - - def self.parser_name - return {{@type.name}} - end - end -end - -# The following are the extractors for extracting an array of items from -# the internal Youtube API's JSON response. The result is then packaged into -# a structure we can more easily use via the parsers above. Their internals are -# identical to the item parsers. - -# Namespace for logic relating to extracting InnerTube's initial response to items we can parse. -# -# Each of the extractors in this namespace are accessed through the #process() method -# which validates the given data as applicable to itself. If it is applicable the given -# data is passed to the private `#extract()` method which returns an array of -# parsable items. Otherwise, nil is returned. -# -# NOTE perhaps the result from here should be abstracted into a struct in order to -# get additional metadata regarding the container of the item(s). -private module Extractors - # Extracts items from the selected YouTube tab. - # - # YouTube tabs are typically stored under "twoColumnBrowseResultsRenderer" - # and is structured like this: - # - # "twoColumnBrowseResultsRenderer": { - # {"tabs": [ - # {"tabRenderer": { - # "endpoint": {...} - # "title": "Playlists", - # "selected": true, - # "content": {...}, - # ... - # }} - # ]} - # }] - # - module YouTubeTabs - def self.process(initial_data : Hash(String, JSON::Any)) - if target = initial_data["twoColumnBrowseResultsRenderer"]? - self.extract(target) - end - end - - private def self.extract(target) - raw_items = [] of JSON::Any - content = extract_selected_tab(target["tabs"])["content"] - - content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| - renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] - - # Category extraction - if items_container = renderer_container_contents["shelfRenderer"]? - raw_items << renderer_container_contents - next - elsif items_container = renderer_container_contents["gridRenderer"]? - else - items_container = renderer_container_contents - end - - items_container["items"]?.try &.as_a.each do |item| - raw_items << item - end - end - - return raw_items - end - - def self.extractor_name - return {{@type.name}} - end - end - - # Extracts items from the InnerTube response for search results - # - # Search results are typically stored under "twoColumnSearchResultsRenderer" - # and is structured like this: - # - # "twoColumnSearchResultsRenderer": { - # {"primaryContents": { - # {"sectionListRenderer": { - # "contents": [...], - # ..., - # "subMenu": {...}, - # "hideBottomSeparator": true, - # "targetId": "search-feed" - # }} - # }} - # } - # - module SearchResults - def self.process(initial_data : Hash(String, JSON::Any)) - if target = initial_data["twoColumnSearchResultsRenderer"]? - self.extract(target) - end - end - - private def self.extract(target) - raw_items = [] of Array(JSON::Any) - - target.dig("primaryContents", "sectionListRenderer", "contents").as_a.each do |node| - if node = node["itemSectionRenderer"]? - raw_items << node["contents"].as_a - end - end - - return raw_items.flatten - end - - def self.extractor_name - return {{@type.name}} - end - end - - # Extracts continuation items from a InnerTube response - # - # Continuation items (on YouTube) are items which are appended to the - # end of the page for continuous scrolling. As such, in many cases, - # the items are lacking information such as author or category title, - # since the original results has already rendered them on the top of the page. - # - # The way they are structured is too varied to be accurately written down here. - # However, they all eventually lead to an array of parsable items after traversing - # through the JSON structure. - module Continuation - def self.process(initial_data : Hash(String, JSON::Any)) - if target = initial_data["continuationContents"]? - self.extract(target) - elsif target = initial_data["appendContinuationItemsAction"]? - self.extract(target) - end - end - - private def self.extract(target) - raw_items = [] of JSON::Any - if content = target["gridContinuation"]? - raw_items = content["items"].as_a - elsif content = target["continuationItems"]? - raw_items = content.as_a - end - - return raw_items - end - - def self.extractor_name - return {{@type.name}} - end - end -end - -# Helper methods to aid in the parsing of InnerTube to data structs. -# -# Mostly used to extract out repeated structures to deal with code -# repetition. -private module HelperExtractors - # Retrieves the amount of videos present within the given InnerTube data. - # - # Returns a 0 when it's unable to do so - def self.get_video_count(container : JSON::Any) : Int32 - if box = container["videoCountText"]? - return extract_text(box).try &.gsub(/\D/, "").to_i || 0 - elsif box = container["videoCount"]? - return box.as_s.to_i - else - return 0 - end - end - - # Retrieve lowest quality thumbnail from InnerTube data - # - # TODO allow configuration of image quality (-1 is highest) - # - # Raises when it's unable to parse from the given JSON data. - def self.get_thumbnails(container : JSON::Any) : String - return container.dig("thumbnail", "thumbnails", 0, "url").as_s - end - - # ditto - # - # YouTube sometimes sends the thumbnail as: - # {"thumbnails": [{"thumbnails": [{"url": "example.com"}, ...]}]} - def self.get_thumbnails_plural(container : JSON::Any) : String - return container.dig("thumbnails", 0, "thumbnails", 0, "url").as_s - end - - # Retrieves the ID required for querying the InnerTube browse endpoint. - # Raises when it's unable to do so - def self.get_browse_id(container) - return container.dig("navigationEndpoint", "browseEndpoint", "browseId").as_s - end -end - -# Extracts text from InnerTube response -# -# InnerTube can package text in three different formats -# "runs": [ -# {"text": "something"}, -# {"text": "cont"}, -# ... -# ] -# -# "SimpleText": "something" -# -# Or sometimes just none at all as with the data returned from -# category continuations. -# -# In order to facilitate calling this function with `#[]?`: -# A nil will be accepted. Of course, since nil cannot be parsed, -# another nil will be returned. -def extract_text(item : JSON::Any?) : String? - if item.nil? - return nil - end - - if text_container = item["simpleText"]? - return text_container.as_s - elsif text_container = item["runs"]? - return text_container.as_a.map(&.["text"].as_s).join("") - else - nil - end -end - -# Parses an item from Youtube's JSON response into a more usable structure. -# The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. -def extract_item(item : JSON::Any, author_fallback : String? = "", - author_id_fallback : String? = "") - # We "allow" nil values but secretly use empty strings instead. This is to save us the - # hassle of modifying every author_fallback and author_id_fallback arg usage - # which is more often than not nil. - author_fallback = AuthorFallback.new(author_fallback || "", author_id_fallback || "") - - # Cycles through all of the item parsers and attempt to parse the raw YT JSON data. - # Each parser automatically validates the data given to see if the data is - # applicable to itself. If not nil is returned and the next parser is attemped. - ITEM_PARSERS.each do |parser| - LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") - - if result = parser.process(item, author_fallback) - LOGGER.debug("extract_item: Successfully parsed via #{parser.parser_name}") - - return result - else - LOGGER.trace("extract_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...") - end - end -end - -# Parses multiple items from YouTube's initial JSON response into a more usable structure. -# The end result is an array of SearchItem. -def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, - author_id_fallback : String? = nil) : Array(SearchItem) - items = [] of SearchItem - - if unpackaged_data = initial_data["contents"]?.try &.as_h - elsif unpackaged_data = initial_data["response"]?.try &.as_h - elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h - else - unpackaged_data = initial_data - end - - # This is identical to the parser cycling of extract_item(). - ITEM_CONTAINER_EXTRACTOR.each do |extractor| - LOGGER.trace("extract_items: Attempting to extract item container using \"#{extractor.extractor_name}\" (cycling...)") - - if container = extractor.process(unpackaged_data) - LOGGER.debug("extract_items: Successfully unpacked container with \"#{extractor.extractor_name}\"") - # Extract items in container - container.each do |item| - if parsed_result = extract_item(item, author_fallback, author_id_fallback) - items << parsed_result - end - end - - break - else - LOGGER.trace("extract_items: Extractor \"#{extractor.extractor_name}\" does not apply. Cycling to the next one...") - end - end - - return items -end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr new file mode 100644 index 00000000..0277d43b --- /dev/null +++ b/src/invidious/yt_backend/extractors.cr @@ -0,0 +1,625 @@ +# This file contains helper methods to parse the Youtube API json data into +# neat little packages we can use + +# Tuple of Parsers/Extractors so we can easily cycle through them. +private ITEM_CONTAINER_EXTRACTOR = { + Extractors::YouTubeTabs, + Extractors::SearchResults, + Extractors::Continuation, +} + +private ITEM_PARSERS = { + Parsers::VideoRendererParser, + Parsers::ChannelRendererParser, + Parsers::GridPlaylistRendererParser, + Parsers::PlaylistRendererParser, + Parsers::CategoryRendererParser, +} + +record AuthorFallback, name : String, id : String + +# Namespace for logic relating to parsing InnerTube data into various datastructs. +# +# Each of the parsers in this namespace are accessed through the #process() method +# which validates the given data as applicable to itself. If it is applicable the given +# data is passed to the private `#parse()` method which returns a datastruct of the given +# type. Otherwise, nil is returned. +private module Parsers + # Parses a InnerTube videoRenderer into a SearchVideo. Returns nil when the given object isn't a videoRenderer + # + # A videoRenderer renders a video to click on within the YouTube and Invidious UI. It is **not** + # the watchable video itself. + # + # See specs for example. + # + # `videoRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. + # + module VideoRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = (item["videoRenderer"]? || item["gridVideoRenderer"]?) + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + video_id = item_contents["videoId"].as_s + title = extract_text(item_contents["title"]?) || "" + + # Extract author information + if author_info = item_contents.dig?("ownerText", "runs", 0) + author = author_info["text"].as_s + author_id = HelperExtractors.get_browse_id(author_info) + else + author = author_fallback.name + author_id = author_fallback.id + end + + # For live videos (and possibly recently premiered videos) there is no published information. + # Instead, in its place is the amount of people currently watching. This behavior should be replicated + # on Invidious once all features of livestreams are supported. On an unrelated note, defaulting to the current + # time for publishing isn't a good idea. + published = item_contents.dig?("publishedTimeText", "simpleText").try { |t| decode_date(t.as_s) } || Time.local + + # Typically views are stored under a "simpleText" in the "viewCountText". However, for + # livestreams and premiered it is stored under a "runs" array: [{"text":123}, {"text": "watching"}] + # When view count is disabled the "viewCountText" is not present on InnerTube data. + # TODO change default value to nil and typical encoding type to tuple storing type (watchers, views, etc) + # and count + view_count = item_contents.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 + description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" + + # The length information *should* only always exist in "lengthText". However, the legacy Invidious code + # extracts from "thumbnailOverlays" when it doesn't. More testing is needed to see if this is + # actually needed + if length_container = item_contents["lengthText"]? + length_seconds = decode_length_seconds(length_container["simpleText"].as_s) + elsif length_container = item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?) + # This needs to only go down the `simpleText` path (if possible). If more situations came up that requires + # a specific pathway then we should add an argument to extract_text that'll make this possible + length_seconds = length_container.dig?("thumbnailOverlayTimeStatusRenderer", "text", "simpleText") + + if length_seconds + length_seconds = decode_length_seconds(length_seconds.as_s) + else + length_seconds = 0 + end + else + length_seconds = 0 + end + + live_now = false + paid = false + premium = false + + premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } + + item_contents["badges"]?.try &.as_a.each do |badge| + b = badge["metadataBadgeRenderer"] + case b["label"].as_s + when "LIVE NOW" + live_now = true + when "New", "4K", "CC" + # TODO + when "Premium" + # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] + premium = true + else nil # Ignore + end + end + + SearchVideo.new({ + title: title, + id: video_id, + author: author, + ucid: author_id, + published: published, + views: view_count, + description_html: description_html, + length_seconds: length_seconds, + live_now: live_now, + premium: premium, + premiere_timestamp: premiere_timestamp, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses a InnerTube channelRenderer into a SearchChannel. Returns nil when the given object isn't a channelRenderer + # + # A channelRenderer renders a channel to click on within the YouTube and Invidious UI. It is **not** + # the channel page itself. + # + # See specs for example. + # + # `channelRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. + # + module ChannelRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = (item["channelRenderer"]? || item["gridChannelRenderer"]?) + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + author = extract_text(item_contents["title"]) || author_fallback.name + author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id + + author_thumbnail = HelperExtractors.get_thumbnails(item_contents) + # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. + # Always simpleText + # TODO change default value to nil + subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") + .try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 + + # Auto-generated channels doesn't have videoCountText + # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922 + auto_generated = item_contents["videoCountText"]?.nil? + + video_count = HelperExtractors.get_video_count(item_contents) + description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" + + SearchChannel.new({ + author: author, + ucid: author_id, + author_thumbnail: author_thumbnail, + subscriber_count: subscriber_count, + video_count: video_count, + description_html: description_html, + auto_generated: auto_generated, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses a InnerTube gridPlaylistRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridPlaylistRenderer + # + # A gridPlaylistRenderer renders a playlist, that is located in a grid, to click on within the YouTube and Invidious UI. + # It is **not** the playlist itself. + # + # See specs for example. + # + # `gridPlaylistRenderer`s can be found on the playlist-tabs of channels and expanded categories. + # + module GridPlaylistRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["gridPlaylistRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + title = extract_text(item_contents["title"]) || "" + plid = item_contents["playlistId"]?.try &.as_s || "" + + video_count = HelperExtractors.get_video_count(item_contents) + playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents) + + SearchPlaylist.new({ + title: title, + id: plid, + author: author_fallback.name, + ucid: author_fallback.id, + video_count: video_count, + videos: [] of SearchPlaylistVideo, + thumbnail: playlist_thumbnail, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses a InnerTube playlistRenderer into a SearchPlaylist. Returns nil when the given object isn't a playlistRenderer + # + # A playlistRenderer renders a playlist to click on within the YouTube and Invidious UI. It is **not** the playlist itself. + # + # See specs for example. + # + # `playlistRenderer`s can be found almost everywhere on YouTube. In categories, search results, recommended, etc. + # + module PlaylistRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["playlistRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + title = item_contents["title"]["simpleText"]?.try &.as_s || "" + plid = item_contents["playlistId"]?.try &.as_s || "" + + video_count = HelperExtractors.get_video_count(item_contents) + playlist_thumbnail = HelperExtractors.get_thumbnails_plural(item_contents) + + author_info = item_contents.dig?("shortBylineText", "runs", 0) + author = author_info.try &.["text"].as_s || author_fallback.name + author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id + + videos = item_contents["videos"]?.try &.as_a.map do |v| + v = v["childVideoRenderer"] + v_title = v.dig?("title", "simpleText").try &.as_s || "" + v_id = v["videoId"]?.try &.as_s || "" + v_length_seconds = v.dig?("lengthText", "simpleText").try { |t| decode_length_seconds(t.as_s) } || 0 + SearchPlaylistVideo.new({ + title: v_title, + id: v_id, + length_seconds: v_length_seconds, + }) + end || [] of SearchPlaylistVideo + + # TODO: item_contents["publishedTimeText"]? + + SearchPlaylist.new({ + title: title, + id: plid, + author: author, + ucid: author_id, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + + # Parses a InnerTube shelfRenderer into a Category. Returns nil when the given object isn't a shelfRenderer + # + # A shelfRenderer renders divided sections on YouTube. IE "People also watched" in search results and + # the various organizational sections in the channel home page. A separate one (richShelfRenderer) is used + # for YouTube home. A shelfRenderer can also sometimes be expanded to show more content within it. + # + # See specs for example. + # + # `shelfRenderer`s can be found almost everywhere on YouTube. In categories, search results, channels, etc. + # + module CategoryRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["shelfRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + title = extract_text(item_contents["title"]?) || "" + url = item_contents.dig?("endpoint", "commandMetadata", "webCommandMetadata", "url") + .try &.as_s + + # Sometimes a category can have badges. + badges = [] of Tuple(String, String) # (Badge style, label) + item_contents["badges"]?.try &.as_a.each do |badge| + badge = badge["metadataBadgeRenderer"] + badges << {badge["style"].as_s, badge["label"].as_s} + end + + # Category description + description_html = item_contents["subtitle"]?.try { |desc| parse_content(desc) } || "" + + # Content parsing + contents = [] of SearchItem + + # InnerTube recognizes some "special" categories, which are organized differently. + if special_category_container = item_contents["content"]? + if content_container = special_category_container["horizontalListRenderer"]? + elsif content_container = special_category_container["expandedShelfContentsRenderer"]? + elsif content_container = special_category_container["verticalListRenderer"]? + else + # Anything else, such as `horizontalMovieListRenderer` is currently unsupported. + return + end + else + # "Normal" category. + content_container = item_contents["contents"] + end + + raw_contents = content_container["items"]?.try &.as_a + if !raw_contents.nil? + raw_contents.each do |item| + result = extract_item(item) + if !result.nil? + contents << result + end + end + end + + Category.new({ + title: title, + contents: contents, + description_html: description_html, + url: url, + badges: badges, + }) + end + + def self.parser_name + return {{@type.name}} + end + end +end + +# The following are the extractors for extracting an array of items from +# the internal Youtube API's JSON response. The result is then packaged into +# a structure we can more easily use via the parsers above. Their internals are +# identical to the item parsers. + +# Namespace for logic relating to extracting InnerTube's initial response to items we can parse. +# +# Each of the extractors in this namespace are accessed through the #process() method +# which validates the given data as applicable to itself. If it is applicable the given +# data is passed to the private `#extract()` method which returns an array of +# parsable items. Otherwise, nil is returned. +# +# NOTE perhaps the result from here should be abstracted into a struct in order to +# get additional metadata regarding the container of the item(s). +private module Extractors + # Extracts items from the selected YouTube tab. + # + # YouTube tabs are typically stored under "twoColumnBrowseResultsRenderer" + # and is structured like this: + # + # "twoColumnBrowseResultsRenderer": { + # {"tabs": [ + # {"tabRenderer": { + # "endpoint": {...} + # "title": "Playlists", + # "selected": true, + # "content": {...}, + # ... + # }} + # ]} + # }] + # + module YouTubeTabs + def self.process(initial_data : Hash(String, JSON::Any)) + if target = initial_data["twoColumnBrowseResultsRenderer"]? + self.extract(target) + end + end + + private def self.extract(target) + raw_items = [] of JSON::Any + content = extract_selected_tab(target["tabs"])["content"] + + content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| + renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] + + # Category extraction + if items_container = renderer_container_contents["shelfRenderer"]? + raw_items << renderer_container_contents + next + elsif items_container = renderer_container_contents["gridRenderer"]? + else + items_container = renderer_container_contents + end + + items_container["items"]?.try &.as_a.each do |item| + raw_items << item + end + end + + return raw_items + end + + def self.extractor_name + return {{@type.name}} + end + end + + # Extracts items from the InnerTube response for search results + # + # Search results are typically stored under "twoColumnSearchResultsRenderer" + # and is structured like this: + # + # "twoColumnSearchResultsRenderer": { + # {"primaryContents": { + # {"sectionListRenderer": { + # "contents": [...], + # ..., + # "subMenu": {...}, + # "hideBottomSeparator": true, + # "targetId": "search-feed" + # }} + # }} + # } + # + module SearchResults + def self.process(initial_data : Hash(String, JSON::Any)) + if target = initial_data["twoColumnSearchResultsRenderer"]? + self.extract(target) + end + end + + private def self.extract(target) + raw_items = [] of Array(JSON::Any) + + target.dig("primaryContents", "sectionListRenderer", "contents").as_a.each do |node| + if node = node["itemSectionRenderer"]? + raw_items << node["contents"].as_a + end + end + + return raw_items.flatten + end + + def self.extractor_name + return {{@type.name}} + end + end + + # Extracts continuation items from a InnerTube response + # + # Continuation items (on YouTube) are items which are appended to the + # end of the page for continuous scrolling. As such, in many cases, + # the items are lacking information such as author or category title, + # since the original results has already rendered them on the top of the page. + # + # The way they are structured is too varied to be accurately written down here. + # However, they all eventually lead to an array of parsable items after traversing + # through the JSON structure. + module Continuation + def self.process(initial_data : Hash(String, JSON::Any)) + if target = initial_data["continuationContents"]? + self.extract(target) + elsif target = initial_data["appendContinuationItemsAction"]? + self.extract(target) + end + end + + private def self.extract(target) + raw_items = [] of JSON::Any + if content = target["gridContinuation"]? + raw_items = content["items"].as_a + elsif content = target["continuationItems"]? + raw_items = content.as_a + end + + return raw_items + end + + def self.extractor_name + return {{@type.name}} + end + end +end + +# Helper methods to aid in the parsing of InnerTube to data structs. +# +# Mostly used to extract out repeated structures to deal with code +# repetition. +private module HelperExtractors + # Retrieves the amount of videos present within the given InnerTube data. + # + # Returns a 0 when it's unable to do so + def self.get_video_count(container : JSON::Any) : Int32 + if box = container["videoCountText"]? + return extract_text(box).try &.gsub(/\D/, "").to_i || 0 + elsif box = container["videoCount"]? + return box.as_s.to_i + else + return 0 + end + end + + # Retrieve lowest quality thumbnail from InnerTube data + # + # TODO allow configuration of image quality (-1 is highest) + # + # Raises when it's unable to parse from the given JSON data. + def self.get_thumbnails(container : JSON::Any) : String + return container.dig("thumbnail", "thumbnails", 0, "url").as_s + end + + # ditto + # + # YouTube sometimes sends the thumbnail as: + # {"thumbnails": [{"thumbnails": [{"url": "example.com"}, ...]}]} + def self.get_thumbnails_plural(container : JSON::Any) : String + return container.dig("thumbnails", 0, "thumbnails", 0, "url").as_s + end + + # Retrieves the ID required for querying the InnerTube browse endpoint. + # Raises when it's unable to do so + def self.get_browse_id(container) + return container.dig("navigationEndpoint", "browseEndpoint", "browseId").as_s + end +end + +# Extracts text from InnerTube response +# +# InnerTube can package text in three different formats +# "runs": [ +# {"text": "something"}, +# {"text": "cont"}, +# ... +# ] +# +# "SimpleText": "something" +# +# Or sometimes just none at all as with the data returned from +# category continuations. +# +# In order to facilitate calling this function with `#[]?`: +# A nil will be accepted. Of course, since nil cannot be parsed, +# another nil will be returned. +def extract_text(item : JSON::Any?) : String? + if item.nil? + return nil + end + + if text_container = item["simpleText"]? + return text_container.as_s + elsif text_container = item["runs"]? + return text_container.as_a.map(&.["text"].as_s).join("") + else + nil + end +end + +# Parses an item from Youtube's JSON response into a more usable structure. +# The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. +def extract_item(item : JSON::Any, author_fallback : String? = "", + author_id_fallback : String? = "") + # We "allow" nil values but secretly use empty strings instead. This is to save us the + # hassle of modifying every author_fallback and author_id_fallback arg usage + # which is more often than not nil. + author_fallback = AuthorFallback.new(author_fallback || "", author_id_fallback || "") + + # Cycles through all of the item parsers and attempt to parse the raw YT JSON data. + # Each parser automatically validates the data given to see if the data is + # applicable to itself. If not nil is returned and the next parser is attemped. + ITEM_PARSERS.each do |parser| + LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") + + if result = parser.process(item, author_fallback) + LOGGER.debug("extract_item: Successfully parsed via #{parser.parser_name}") + + return result + else + LOGGER.trace("extract_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...") + end + end +end + +# Parses multiple items from YouTube's initial JSON response into a more usable structure. +# The end result is an array of SearchItem. +def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, + author_id_fallback : String? = nil) : Array(SearchItem) + items = [] of SearchItem + + if unpackaged_data = initial_data["contents"]?.try &.as_h + elsif unpackaged_data = initial_data["response"]?.try &.as_h + elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h + else + unpackaged_data = initial_data + end + + # This is identical to the parser cycling of extract_item(). + ITEM_CONTAINER_EXTRACTOR.each do |extractor| + LOGGER.trace("extract_items: Attempting to extract item container using \"#{extractor.extractor_name}\" (cycling...)") + + if container = extractor.process(unpackaged_data) + LOGGER.debug("extract_items: Successfully unpacked container with \"#{extractor.extractor_name}\"") + # Extract items in container + container.each do |item| + if parsed_result = extract_item(item, author_fallback, author_id_fallback) + items << parsed_result + end + end + + break + else + LOGGER.trace("extract_items: Extractor \"#{extractor.extractor_name}\" does not apply. Cycling to the next one...") + end + end + + return items +end -- cgit v1.2.3 From cb9b84f940a3cb05a35790f73c055d6a5a62ec4f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 18 Oct 2021 16:14:37 +0200 Subject: Move 'extract_text()' to 'extractors_utils.cr' --- src/invidious/yt_backend/extractors.cr | 31 ---------------------------- src/invidious/yt_backend/extractors_utils.cr | 31 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 31 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 0277d43b..8398ca8e 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -533,37 +533,6 @@ private module HelperExtractors end end -# Extracts text from InnerTube response -# -# InnerTube can package text in three different formats -# "runs": [ -# {"text": "something"}, -# {"text": "cont"}, -# ... -# ] -# -# "SimpleText": "something" -# -# Or sometimes just none at all as with the data returned from -# category continuations. -# -# In order to facilitate calling this function with `#[]?`: -# A nil will be accepted. Of course, since nil cannot be parsed, -# another nil will be returned. -def extract_text(item : JSON::Any?) : String? - if item.nil? - return nil - end - - if text_container = item["simpleText"]? - return text_container.as_s - elsif text_container = item["runs"]? - return text_container.as_a.map(&.["text"].as_s).join("") - else - nil - end -end - # Parses an item from Youtube's JSON response into a more usable structure. # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. def extract_item(item : JSON::Any, author_fallback : String? = "", diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index b76fa09a..97cc0997 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -1,3 +1,34 @@ +# Extracts text from InnerTube response +# +# InnerTube can package text in three different formats +# "runs": [ +# {"text": "something"}, +# {"text": "cont"}, +# ... +# ] +# +# "SimpleText": "something" +# +# Or sometimes just none at all as with the data returned from +# category continuations. +# +# In order to facilitate calling this function with `#[]?`: +# A nil will be accepted. Of course, since nil cannot be parsed, +# another nil will be returned. +def extract_text(item : JSON::Any?) : String? + if item.nil? + return nil + end + + if text_container = item["simpleText"]? + return text_container.as_s + elsif text_container = item["runs"]? + return text_container.as_a.map(&.["text"].as_s).join("") + else + nil + end +end + def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) extracted = extract_items(initial_data, author_fallback, author_id_fallback) -- cgit v1.2.3 From d9c58c48370debbbfecf76370b5ea0526277bb5a Mon Sep 17 00:00:00 2001 From: Dmitry Sandalov Date: Thu, 21 Oct 2021 14:54:15 +0300 Subject: Feature Request: Localization for trending iv-org#331 --- config/config.example.yml | 15 +++++++++++++++ locales/en-US.json | 1 + src/invidious/config.cr | 2 ++ src/invidious/routes/feeds.cr | 2 +- src/invidious/routes/preferences.cr | 3 +++ src/invidious/user/preferences.cr | 1 + src/invidious/videos.cr | 2 ++ src/invidious/views/preferences.ecr | 9 +++++++++ 8 files changed, 34 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 8bb19fcc..554c0d46 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -504,6 +504,21 @@ default_user_preferences: ## #locale: en-US + ## + ## Default language for content. + ## + ## Accepted values: + ## AE, AR, AT, AU, AZ, BA, BD, BE, BG, BH, BO, BR, BY, CA, CH, CL, CO, CR, + ## CY, CZ, DE, DK, DO, DZ, EC, EE, EG, ES, FI, FR, GB, GE, GH, GR, GT, HK, + ## HN, HR, HU, ID, IE, IL, IN, IQ, IS, IT, JM, JO, JP, KE, KR, KW, KZ, LB, + ## LI, LK, LT, LU, LV, LY, MA, ME, MK, MT, MX, MY, NG, NI, NL, NO, NP, NZ, + ## OM, PA, PE, PG, PH, PK, PL, PR, PT, PY, QA, RO, RS, RU, SA, SE, SG, SI, + ## SK, SN, SV, TH, TN, TR, TW, TZ, UA, UG, US, UY, VE, VN, YE, ZA, ZW + ## + ## Default: US + ## + #region: US + ## ## Top 3 prefered languages for video captions. ## diff --git a/locales/en-US.json b/locales/en-US.json index 230d96ad..11aecff4 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -80,6 +80,7 @@ "Automatically extend video description: ": "Automatically extend video description: ", "Interactive 360 degree videos: ": "Interactive 360 degree videos: ", "Visual preferences": "Visual preferences", + "Content country: ": "Content country: ", "Player style: ": "Player style: ", "Dark mode: ": "Dark mode: ", "Theme: ": "Theme: ", diff --git a/src/invidious/config.cr b/src/invidious/config.cr index e2bc5722..baf2702a 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -31,6 +31,7 @@ struct ConfigPreferences property default_home : String? = "Popular" property feed_menu : Array(String) = ["Popular", "Trending", "Subscriptions", "Playlists"] property automatic_instance_redirect : Bool = false + property region : String = "US" property related_videos : Bool = true property sort : String = "published" property speed : Float32 = 1.0_f32 @@ -72,6 +73,7 @@ class Config property captcha_enabled : Bool = true property login_enabled : Bool = true property registration_enabled : Bool = true + property region : String? property statistics_enabled : Bool = false property admins : Array(String) = [] of String property external_port : Int32? = nil diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index d9280529..40c41dc1 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -48,7 +48,7 @@ module Invidious::Routes::Feeds trending_type ||= "Default" region = env.params.query["region"]? - region ||= "US" + region ||= env.get("preferences").as(Preferences).region begin trending, plid = fetch_trending(trending_type, region, locale) diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index ae5407dc..8793d4e9 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -102,6 +102,8 @@ module Invidious::Routes::PreferencesRoute automatic_instance_redirect ||= "off" automatic_instance_redirect = automatic_instance_redirect == "on" + region = env.params.body["region"]?.try &.as(String) + locale = env.params.body["locale"]?.try &.as(String) locale ||= CONFIG.default_user_preferences.locale @@ -152,6 +154,7 @@ module Invidious::Routes::PreferencesRoute default_home: default_home, feed_menu: feed_menu, automatic_instance_redirect: automatic_instance_redirect, + region: region, related_videos: related_videos, sort: sort, speed: speed, diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr index 453a635e..fd7b4763 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -28,6 +28,7 @@ struct Preferences @[JSON::Field(converter: Preferences::ProcessString)] property locale : String = CONFIG.default_user_preferences.locale + property region : String? = CONFIG.region != nil ? CONFIG.region : CONFIG.default_user_preferences.region @[JSON::Field(converter: Preferences::ClampInt)] property max_results : Int32 = CONFIG.default_user_preferences.max_results diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 0e6bd77c..4c200409 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -110,6 +110,8 @@ CAPTION_LANGUAGES = { REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"} +TRENDING_REGIONS = {"AE", "AR", "AT", "AU", "AZ", "BA", "BD", "BE", "BG", "BH", "BO", "BR", "BY", "CA", "CH", "CL", "CO", "CR", "CY", "CZ", "DE", "DK", "DO", "DZ", "EC", "EE", "EG", "ES", "FI", "FR", "GB", "GE", "GH", "GR", "GT", "HK", "HN", "HR", "HU", "ID", "IE", "IL", "IN", "IQ", "IS", "IT", "JM", "JO", "JP", "KE", "KR", "KW", "KZ", "LB", "LI", "LK", "LT", "LU", "LV", "LY", "MA", "ME", "MK", "MT", "MX", "MY", "NG", "NI", "NL", "NO", "NP", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", "PL", "PR", "PT", "PY", "QA", "RO", "RS", "RU", "SA", "SE", "SG", "SI", "SK", "SN", "SV", "TH", "TN", "TR", "TW", "TZ", "UA", "UG", "US", "UY", "VE", "VN", "YE", "ZA", "ZW"} + # See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 VIDEO_FORMATS = { "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 401c15ea..ad9cb089 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -127,6 +127,15 @@
    +
    + + +
    +
    - <% TRENDING_REGIONS.each do |option| %> + <% CONTENT_REGIONS.each do |option| %> <% end %> -- cgit v1.2.3 From 0e17d026f291314776e3850ee7233496bb1c612d Mon Sep 17 00:00:00 2001 From: Dmitry Sandalov Date: Thu, 21 Oct 2021 23:38:49 +0300 Subject: Feature Request: Localization for trending (fix lint CONTENT_REGIONS end comma) --- src/invidious/helpers/i18n.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index b806259b..9e42fad0 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -50,7 +50,7 @@ CONTENT_REGIONS = { "MY", "NG", "NI", "NL", "NO", "NP", "NZ", "OM", "PA", "PE", "PG", "PH", "PK", "PL", "PR", "PT", "PY", "QA", "RO", "RS", "RU", "SA", "SE", "SG", "SI", "SK", "SN", "SV", "TH", "TN", "TR", "TW", "TZ", "UA", "UG", "US", "UY", "VE", "VN", - "YE", "ZA", "ZW" + "YE", "ZA", "ZW", } def load_locale(name) -- cgit v1.2.3 From a629521c37d5d305af7672f51c4e154a1f704056 Mon Sep 17 00:00:00 2001 From: Dmitry Sandalov Date: Mon, 25 Oct 2021 10:57:27 +0300 Subject: Feature Request: Localization for trending (remove region from server config) --- src/invidious/config.cr | 1 - src/invidious/user/preferences.cr | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/config.cr b/src/invidious/config.cr index baf2702a..bacdb4ac 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -73,7 +73,6 @@ class Config property captcha_enabled : Bool = true property login_enabled : Bool = true property registration_enabled : Bool = true - property region : String? property statistics_enabled : Bool = false property admins : Array(String) = [] of String property external_port : Int32? = nil diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr index fd7b4763..c15876f5 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -28,7 +28,7 @@ struct Preferences @[JSON::Field(converter: Preferences::ProcessString)] property locale : String = CONFIG.default_user_preferences.locale - property region : String? = CONFIG.region != nil ? CONFIG.region : CONFIG.default_user_preferences.region + property region : String? = CONFIG.default_user_preferences.region @[JSON::Field(converter: Preferences::ClampInt)] property max_results : Int32 = CONFIG.default_user_preferences.max_results -- cgit v1.2.3 From 0614b52f037127591f12e54e78be0ee22e469b69 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 24 Sep 2021 18:51:07 -0700 Subject: Fix Lint/RedundantStringCoercion issues --- src/invidious/routes/video_playback.cr | 2 +- src/invidious/yt_backend/youtube_api.cr | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index acbf62b4..5c64f669 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -20,7 +20,7 @@ module Invidious::Routes::VideoPlayback host = "https://r#{fvip}---#{mns.pop}.googlevideo.com" end - url = "/videoplayback?#{query_params.to_s}" + url = "/videoplayback?#{query_params}" headers = HTTP::Headers.new REQUEST_HEADERS_WHITELIST.each do |header| diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index b3815f6a..8ab2fe46 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -410,8 +410,8 @@ module YoutubeAPI # Logging LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") - LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config.to_s}") - LOGGER.trace("YoutubeAPI: POST data: #{data.to_s}") + LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}") + LOGGER.trace("YoutubeAPI: POST data: #{data}") # Send the POST request if client_config.proxy_region @@ -436,7 +436,7 @@ module YoutubeAPI # Logging LOGGER.error("YoutubeAPI: Got error #{code} when requesting #{endpoint}") LOGGER.error("YoutubeAPI: #{message}") - LOGGER.info("YoutubeAPI: POST data was: #{data.to_s}") + LOGGER.info("YoutubeAPI: POST data was: #{data}") raise InfoException.new("Could not extract JSON. Youtube API returned \ error #{code} with message:
    \"#{message}\"") -- cgit v1.2.3 From 20cb751ff65a179a694fde4ce2bb60dda0e68b30 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 24 Sep 2021 19:05:25 -0700 Subject: Fix Lint/UnusedArgument issues --- src/invidious.cr | 2 +- src/invidious/channels/community.cr | 2 +- src/invidious/channels/playlists.cr | 2 +- src/invidious/channels/videos.cr | 2 +- src/invidious/comments.cr | 4 ++-- src/invidious/helpers/serialized_yt_data.cr | 2 +- src/invidious/helpers/signatures.cr | 2 +- src/invidious/playlists.cr | 6 +++--- src/invidious/routes/api/v1/authenticated.cr | 2 +- src/invidious/routes/login.cr | 4 ++-- src/invidious/search.cr | 4 ++-- 11 files changed, 16 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 570c33e6..e2c83bda 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -1351,7 +1351,7 @@ error 500 do |env, ex| error_template(500, ex) end -static_headers do |response, filepath, filestat| +static_headers do | response | response.headers.add("Cache-Control", "max-age=2629800") end diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 97ab30ec..a8bdd2e4 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -242,7 +242,7 @@ def produce_channel_community_continuation(ucid, cursor) }, } - continuation = object.try { |i| Protodec::Any.cast_json(object) } + continuation = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index 393b055e..3072b21d 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -84,7 +84,7 @@ def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) object["80226972:embedded"].delete("3:base64") - continuation = object.try { |i| Protodec::Any.cast_json(object) } + continuation = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index 2c43bf0b..48453bb7 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -49,7 +49,7 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) object["80226972:embedded"].delete("3:base64") - continuation = object.try { |i| Protodec::Any.cast_json(object) } + continuation = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 9c788253..caeaa0e8 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -638,7 +638,7 @@ def produce_comment_continuation(video_id, cursor = "", sort_by = "top") object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 end - continuation = object.try { |i| Protodec::Any.cast_json(object) } + continuation = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } @@ -673,7 +673,7 @@ def produce_comment_reply_continuation(video_id, ucid, comment_id) }, } - continuation = object.try { |i| Protodec::Any.cast_json(object) } + continuation = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index a9798f0c..1ba3f20e 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -62,7 +62,7 @@ struct SearchVideo if xml to_xml(HOST_URL, auto_generated, query_params, xml) else - XML.build do |json| + XML.build do |xml| to_xml(HOST_URL, auto_generated, query_params, xml) end end diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index d8b1de65..ee09415b 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -30,7 +30,7 @@ struct DecryptFunction case op_body when "{a.reverse()" - operations[op_name] = ->(a : Array(String), b : Int32) { a.reverse } + operations[op_name] = ->(a : Array(String), _b : Int32) { a.reverse } when "{a.splice(0,b)" operations[op_name] = ->(a : Array(String), b : Int32) { a.delete_at(0..(b - 1)); a } else diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 7940dc1f..70c74eb3 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -51,7 +51,7 @@ struct PlaylistVideo if xml to_xml(auto_generated, xml) else - XML.build do |json| + XML.build do |xml| # Why was this `json`? to_xml(auto_generated, xml) end end @@ -143,7 +143,7 @@ struct Playlist json.field "videos" do json.array do videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id) - videos.each_with_index do |video, index| + videos.each do |video| video.to_json(locale, json) end end @@ -336,7 +336,7 @@ def produce_playlist_continuation(id, index) }, } - continuation = object.try { |i| Protodec::Any.cast_json(object) } + continuation = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index b4e9e9c8..f7a2ad78 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -335,7 +335,7 @@ module Invidious::Routes::API::V1::Authenticated case env.request.headers["Content-Type"]? when "application/x-www-form-urlencoded" - scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } + scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v } callback_url = env.params.body["callbackUrl"]? expire = env.params.body["expire"]?.try &.to_i? when "application/json" diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index e7aef289..b719b571 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -395,7 +395,7 @@ module Invidious::Routes::Login return templated "login" end - tokens = env.params.body.select { |k, v| k.match(/^token\[\d+\]$/) }.map { |k, v| v } + tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v } answer ||= "" captcha_type ||= "image" @@ -419,7 +419,7 @@ module Invidious::Routes::Login found_valid_captcha = false error_exception = Exception.new - tokens.each_with_index do |token, i| + tokens.each do |token| begin validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale) found_valid_captcha = true diff --git a/src/invidious/search.cr b/src/invidious/search.cr index d95d802e..f59c9a14 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -128,7 +128,7 @@ def produce_search_params(page = 1, sort : String = "relevance", date : String = object.delete("2:embedded") end - params = object.try { |i| Protodec::Any.cast_json(object) } + params = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } @@ -161,7 +161,7 @@ def produce_channel_search_continuation(ucid, query, page) }, } - continuation = object.try { |i| Protodec::Any.cast_json(object) } + continuation = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } -- cgit v1.2.3 From 35d15c7c2b5d71582f7ce4b7875a777ccb0ebcd8 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 24 Sep 2021 19:15:23 -0700 Subject: Fix Style/VariableNames issues --- src/invidious.cr | 8 ++++---- src/invidious/channels/playlists.cr | 8 ++++---- src/invidious/comments.cr | 25 +++++++++++++------------ src/invidious/routes/api/v1/videos.cr | 8 ++++---- src/invidious/routes/embed.cr | 4 ++-- src/invidious/routes/watch.cr | 4 ++-- src/invidious/search.cr | 6 +++--- src/invidious/videos.cr | 18 +++++++++--------- src/invidious/views/watch.ecr | 2 +- 9 files changed, 42 insertions(+), 41 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index e2c83bda..f41ae6f8 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -703,13 +703,13 @@ get "/subscription_manager" do |env| xml.element("outline", text: title, title: title) do subscriptions.each do |channel| if format == "newpipe" - xmlUrl = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}" + xml_url = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}" else - xmlUrl = "#{HOST_URL}/feed/channel/#{channel.id}" + xml_url = "#{HOST_URL}/feed/channel/#{channel.id}" end xml.element("outline", text: channel.author, title: channel.author, - "type": "rss", xmlUrl: xmlUrl) + "type": "rss", xmlUrl: xml_url) end end end @@ -1351,7 +1351,7 @@ error 500 do |env, ex| error_template(500, ex) end -static_headers do | response | +static_headers do |response| response.headers.add("Cache-Control", "max-age=2629800") end diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index 3072b21d..d5628f6a 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -1,17 +1,17 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) if continuation response_json = YoutubeAPI.browse(continuation) - continuationItems = response_json["onResponseReceivedActions"]? + continuation_items = response_json["onResponseReceivedActions"]? .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - return [] of SearchItem, nil if !continuationItems + return [] of SearchItem, nil if !continuation_items items = [] of SearchItem - continuationItems.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item| + continuation_items.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item| extract_item(item, author, ucid).try { |t| items << t } } - continuation = continuationItems.as_a.last["continuationItemRenderer"]? + continuation = continuation_items.as_a.last["continuationItemRenderer"]? .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s else url = "/channel/#{ucid}/playlists?flow=list&view=1" diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index caeaa0e8..3370b088 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -73,9 +73,9 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b contents = nil if response["onResponseReceivedEndpoints"]? - onResponseReceivedEndpoints = response["onResponseReceivedEndpoints"] + on_response_received_endpoints = response["onResponseReceivedEndpoints"] header = nil - onResponseReceivedEndpoints.as_a.each do |item| + on_response_received_endpoints.as_a.each do |item| if item["reloadContinuationItemsCommand"]? case item["reloadContinuationItemsCommand"]["slot"] when "RELOAD_CONTINUATION_SLOT_HEADER" @@ -97,7 +97,8 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b contents = body["contents"]? header = body["header"]? if body["continuations"]? - moreRepliesContinuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s + # Removable? Doesn't seem like this is used. + more_replies_continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s end else raise InfoException.new("Could not fetch comments") @@ -111,10 +112,10 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b end end - continuationItemRenderer = nil + continuation_item_renderer = nil contents.as_a.reject! do |item| if item["continuationItemRenderer"]? - continuationItemRenderer = item["continuationItemRenderer"] + continuation_item_renderer = item["continuationItemRenderer"] true end end @@ -232,14 +233,14 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b end end - if continuationItemRenderer - if continuationItemRenderer["continuationEndpoint"]? - continuationEndpoint = continuationItemRenderer["continuationEndpoint"] - elsif continuationItemRenderer["button"]? - continuationEndpoint = continuationItemRenderer["button"]["buttonRenderer"]["command"] + if continuation_item_renderer + if continuation_item_renderer["continuationEndpoint"]? + continuation_endpoint = continuation_item_renderer["continuationEndpoint"] + elsif continuation_item_renderer["button"]? + continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"] end - if continuationEndpoint - json.field "continuation", continuationEndpoint["continuationCommand"]["token"].as_s + if continuation_endpoint + json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s end end end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 575e6fdf..d483bca6 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -58,7 +58,7 @@ module Invidious::Routes::API::V1::Videos captions.each do |caption| json.object do json.field "label", caption.name - json.field "languageCode", caption.languageCode + json.field "languageCode", caption.language_code json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" end end @@ -73,7 +73,7 @@ module Invidious::Routes::API::V1::Videos env.response.content_type = "text/vtt; charset=UTF-8" if lang - caption = captions.select { |caption| caption.languageCode == lang } + caption = captions.select { |caption| caption.language_code == lang } else caption = captions.select { |caption| caption.name == label } end @@ -84,7 +84,7 @@ module Invidious::Routes::API::V1::Videos caption = caption[0] end - url = URI.parse("#{caption.baseUrl}&tlang=#{tlang}").request_target + url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target # Auto-generated captions often have cues that aren't aligned properly with the video, # as well as some other markup that makes it cumbersome, so we try to fix that here @@ -96,7 +96,7 @@ module Invidious::Routes::API::V1::Videos str << <<-END_VTT WEBVTT Kind: captions - Language: #{tlang || caption.languageCode} + Language: #{tlang || caption.language_code} END_VTT diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 80d09789..ffbf8c14 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -168,11 +168,11 @@ module Invidious::Routes::Embed preferred_captions = captions.select { |caption| params.preferred_captions.includes?(caption.name) || - params.preferred_captions.includes?(caption.languageCode.split("-")[0]) + params.preferred_captions.includes?(caption.language_code.split("-")[0]) } preferred_captions.sort_by! { |caption| (params.preferred_captions.index(caption.name) || - params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil! + params.preferred_captions.index(caption.language_code.split("-")[0])).not_nil! } captions = captions - preferred_captions diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 2db133ee..abcf427e 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -153,11 +153,11 @@ module Invidious::Routes::Watch preferred_captions = captions.select { |caption| params.preferred_captions.includes?(caption.name) || - params.preferred_captions.includes?(caption.languageCode.split("-")[0]) + params.preferred_captions.includes?(caption.language_code.split("-")[0]) } preferred_captions.sort_by! { |caption| (params.preferred_captions.index(caption.name) || - params.preferred_captions.index(caption.languageCode.split("-")[0])).not_nil! + params.preferred_captions.index(caption.language_code.split("-")[0])).not_nil! } captions = captions - preferred_captions diff --git a/src/invidious/search.cr b/src/invidious/search.cr index f59c9a14..f06ee7f6 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -14,13 +14,13 @@ def channel_search(query, page, channel) continuation = produce_channel_search_continuation(ucid, query, page) response_json = YoutubeAPI.browse(continuation) - continuationItems = response_json["onResponseReceivedActions"]? + continuation_items = response_json["onResponseReceivedActions"]? .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - return 0, [] of SearchItem if !continuationItems + return 0, [] of SearchItem if !continuation_items items = [] of SearchItem - continuationItems.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each { |item| + continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each { |item| extract_item(item["itemSectionRenderer"]["contents"].as_a[0]) .try { |t| items << t } } diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 0e6bd77c..0fe949c7 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -426,7 +426,7 @@ struct Video self.captions.each do |caption| json.object do json.field "label", caption.name - json.field "languageCode", caption.languageCode + json.field "language_code", caption.language_code json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" end end @@ -703,10 +703,10 @@ struct Video return @captions.as(Array(Caption)) if @captions captions = info["captions"]?.try &.["playerCaptionsTracklistRenderer"]?.try &.["captionTracks"]?.try &.as_a.map do |caption| name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] - languageCode = caption["languageCode"].to_s - baseUrl = caption["baseUrl"].to_s + language_code = caption["languageCode"].to_s + base_url = caption["baseUrl"].to_s - caption = Caption.new(name.to_s, languageCode, baseUrl) + caption = Caption.new(name.to_s, language_code, base_url) caption.name = caption.name.split(" - ")[0] caption end @@ -785,16 +785,16 @@ end struct Caption property name - property languageCode - property baseUrl + property language_code + property base_url getter name : String - getter languageCode : String - getter baseUrl : String + getter language_code : String + getter base_url : String setter name - def initialize(@name, @languageCode, @baseUrl) + def initialize(@name, @language_code, @base_url) end end diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 398e25b6..265d1628 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -184,7 +184,7 @@ we're going to need to do it here in order to allow for translations. <% end %> <% captions.each do |caption| %> - <% end %> -- cgit v1.2.3 From e91421253e07903c973ddec758abfc633ed58cf9 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 24 Sep 2021 19:42:43 -0700 Subject: Fix Style/VerboseBlock issues --- src/invidious.cr | 10 +++++----- src/invidious/channels/about.cr | 6 +++--- src/invidious/channels/channels.cr | 2 +- src/invidious/channels/community.cr | 4 ++-- src/invidious/helpers/helpers.cr | 8 ++++---- src/invidious/helpers/static_file_handler.cr | 2 +- src/invidious/helpers/tokens.cr | 8 ++++---- src/invidious/mixes.cr | 2 +- src/invidious/routes/api/v1/authenticated.cr | 2 +- src/invidious/routes/api/v1/search.cr | 2 +- src/invidious/routes/channels.cr | 4 ++-- src/invidious/search.cr | 2 +- src/invidious/users.cr | 22 +++++++++++----------- src/invidious/videos.cr | 12 ++++++------ src/invidious/yt_backend/proxy.cr | 2 +- 15 files changed, 44 insertions(+), 44 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index f41ae6f8..21a12ff2 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -655,7 +655,7 @@ get "/subscription_manager" do |env| end subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) - subscriptions.sort_by! { |channel| channel.author.downcase } + subscriptions.sort_by!(&.author.downcase) if action_takeout if format == "json" @@ -759,7 +759,7 @@ post "/data_control" do |env| body = JSON.parse(body) if body["subscriptions"]? - user.subscriptions += body["subscriptions"].as_a.map { |a| a.as_s } + user.subscriptions += body["subscriptions"].as_a.map(&.as_s) user.subscriptions.uniq! user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) @@ -768,7 +768,7 @@ post "/data_control" do |env| end if body["watch_history"]? - user.watched += body["watch_history"].as_a.map { |a| a.as_s } + user.watched += body["watch_history"].as_a.map(&.as_s) user.watched.uniq! PG_DB.exec("UPDATE users SET watched = $1 WHERE email = $2", user.watched, user.email) end @@ -876,12 +876,12 @@ post "/data_control" do |env| File.write(tempfile.path, entry.io.gets_to_end) db = DB.open("sqlite3://" + tempfile.path) - user.watched += db.query_all("SELECT url FROM streams", as: String).map { |url| url.lchop("https://www.youtube.com/watch?v=") } + user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v=")) user.watched.uniq! PG_DB.exec("UPDATE users SET watched = $1 WHERE email = $2", user.watched, user.email) - user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map { |url| url.lchop("https://www.youtube.com/channel/") } + user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/")) user.subscriptions.uniq! user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 628d5b6f..a72acc72 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -62,7 +62,7 @@ def get_about_info(ucid, locale) description_html = HTML.escape(description).gsub("\n", "
    ") is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool - allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s } + allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) related_channels = [] of AboutRelatedChannel else @@ -84,7 +84,7 @@ def get_about_info(ucid, locale) description_html = HTML.escape(description).gsub("\n", "
    ") is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool - allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map { |a| a.as_s } + allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"] .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]? @@ -149,7 +149,7 @@ def get_about_info(ucid, locale) end end end - tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map { |node| node["tabRenderer"]["title"].as_s.downcase } + tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map(&.["tabRenderer"].["title"].as_s.downcase) end sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 70623cc0..827b6534 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -101,7 +101,7 @@ struct ChannelVideo def to_tuple {% begin %} { - {{*@type.instance_vars.map { |var| var.name }}} + {{*@type.instance_vars.map(&.name)}} } {% end %} end diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index a8bdd2e4..9a50f893 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -255,11 +255,11 @@ def extract_channel_community_cursor(continuation) .try { |i| Base64.decode(i) } .try { |i| IO::Memory.new(i) } .try { |i| Protodec::Any.parse(i) } - .try { |i| i["80226972:0:embedded"]["3:1:base64"].as_h } + .try(&.["80226972:0:embedded"]["3:1:base64"].as_h) if object["53:2:embedded"]?.try &.["3:0:embedded"]? object["53:2:embedded"]["3:0:embedded"]["2:0:string"] = object["53:2:embedded"]["3:0:embedded"] - .try { |i| i["2:0:base64"].as_h } + .try(&.["2:0:base64"].as_h) .try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i, padding: false) } diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 2e61d21f..9c053d74 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -89,14 +89,14 @@ def check_table(db, table_name, struct_type = nil) struct_array = struct_type.type_array column_array = get_column_array(db, table_name) column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?[\d\D]*?)\);/) - .try &.["types"].split(",").map { |line| line.strip }.reject &.starts_with?("CONSTRAINT") + .try &.["types"].split(",").map(&.strip).reject &.starts_with?("CONSTRAINT") return if !column_types struct_array.each_with_index do |name, i| if name != column_array[i]? if !column_array[i]? - new_column = column_types.select { |line| line.starts_with? name }[0] + new_column = column_types.select(&.starts_with?(name))[0] LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") next @@ -104,14 +104,14 @@ def check_table(db, table_name, struct_type = nil) # Column doesn't exist if !column_array.includes? name - new_column = column_types.select { |line| line.starts_with? name }[0] + new_column = column_types.select(&.starts_with?(name))[0] db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") end # Column exists but in the wrong position, rotate if struct_array.includes? column_array[i] until name == column_array[i] - new_column = column_types.select { |line| line.starts_with? column_array[i] }[0]?.try &.gsub("#{column_array[i]}", "#{column_array[i]}_new") + new_column = column_types.select(&.starts_with?(column_array[i]))[0]?.try &.gsub("#{column_array[i]}", "#{column_array[i]}_new") # There's a column we didn't expect if !new_column diff --git a/src/invidious/helpers/static_file_handler.cr b/src/invidious/helpers/static_file_handler.cr index be9d36ab..11151874 100644 --- a/src/invidious/helpers/static_file_handler.cr +++ b/src/invidious/helpers/static_file_handler.cr @@ -173,7 +173,7 @@ module Kemal return end - if @cached_files.sum { |element| element[1][:data].bytesize } + (size = File.size(file_path)) < CACHE_LIMIT + if @cached_files.sum(&.[1].[:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT data = Bytes.new(size) File.open(file_path) do |file| file.read(data) diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index a09ce90b..e6d38315 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -46,7 +46,7 @@ def sign_token(key, hash) next if key == "signature" if value.is_a?(JSON::Any) && value.as_a? - value = value.as_a.map { |i| i.as_s } + value = value.as_a.map(&.as_s) end case value @@ -82,7 +82,7 @@ def validate_request(token, session, request, key, db, locale = nil) raise InfoException.new("Erroneous token") end - scopes = token["scopes"].as_a.map { |v| v.as_s } + scopes = token["scopes"].as_a.map(&.as_s) scope = "#{request.method}:#{request.path.lchop("/api/v1/auth/").lstrip("/")}" if !scopes_include_scope(scopes, scope) raise InfoException.new("Invalid scope") @@ -105,11 +105,11 @@ end def scope_includes_scope(scope, subset) methods, endpoint = scope.split(":") - methods = methods.split(";").map { |method| method.upcase }.reject { |method| method.empty? }.sort + methods = methods.split(";").map(&.upcase).reject(&.empty?).sort endpoint = endpoint.downcase subset_methods, subset_endpoint = subset.split(":") - subset_methods = subset_methods.split(";").map { |method| method.upcase }.sort + subset_methods = subset_methods.split(";").map(&.upcase).sort subset_endpoint = subset_endpoint.downcase if methods.empty? diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 63ea434f..3f342b92 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -72,7 +72,7 @@ def fetch_mix(rdid, video_id, cookies = nil, locale = nil) videos += next_page.videos end - videos.uniq! { |video| video.id } + videos.uniq!(&.id) videos = videos.first(50) return Mix.new({ title: mix_title, diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index f7a2ad78..7950b302 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -339,7 +339,7 @@ module Invidious::Routes::API::V1::Authenticated callback_url = env.params.body["callbackUrl"]? expire = env.params.body["expire"]?.try &.to_i? when "application/json" - scopes = env.params.json["scopes"].as(Array).map { |v| v.as_s } + scopes = env.params.json["scopes"].as(Array).map(&.as_s) callback_url = env.params.json["callbackUrl"]?.try &.as(String) expire = env.params.json["expire"]?.try &.as(Int64) else diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index f3a6fa06..7234dcdd 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -20,7 +20,7 @@ module Invidious::Routes::API::V1::Search duration = env.params.query["duration"]?.try &.downcase duration ||= "" - features = env.params.query["features"]?.try &.split(",").map { |feature| feature.downcase } + features = env.params.query["features"]?.try &.split(",").map(&.downcase) features ||= [] of String content_type = env.params.query["type"]?.try &.downcase diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 11c2f869..bfcc3edd 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -30,7 +30,7 @@ module Invidious::Routes::Channels end end items = items.select(&.is_a?(SearchPlaylist)).map(&.as(SearchPlaylist)) - items.each { |item| item.author = "" } + items.each(&.author=("")) else sort_options = {"newest", "oldest", "popular"} sort_by ||= "newest" @@ -58,7 +58,7 @@ module Invidious::Routes::Channels items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) items = items.select { |item| item.is_a?(SearchPlaylist) }.map { |item| item.as(SearchPlaylist) } - items.each { |item| item.author = "" } + items.each(&.author=("")) templated "playlists" end diff --git a/src/invidious/search.cr b/src/invidious/search.cr index f06ee7f6..2095721c 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -183,7 +183,7 @@ def process_search_query(query, page, user, region) sort = "relevance" subscriptions = nil - operators = query.split(" ").select { |a| a.match(/\w+:[\w,]+/) } + operators = query.split(" ").select(&.match(/\w+:[\w,]+/)) operators.each do |operator| key, value = operator.downcase.split(":") diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 8ea7bd4a..584082be 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -248,17 +248,17 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args}) ORDER BY published DESC", args: notifications, as: ChannelVideo) videos = [] of ChannelVideo - notifications.sort_by! { |video| video.published }.reverse! + notifications.sort_by!(&.published).reverse! case user.preferences.sort when "alphabetically" - notifications.sort_by! { |video| video.title } + notifications.sort_by!(&.title) when "alphabetically - reverse" - notifications.sort_by! { |video| video.title }.reverse! + notifications.sort_by!(&.title).reverse! when "channel name" - notifications.sort_by! { |video| video.author } + notifications.sort_by!(&.author) when "channel name - reverse" - notifications.sort_by! { |video| video.author }.reverse! + notifications.sort_by!(&.author).reverse! else nil # Ignore end else @@ -279,7 +279,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) videos = PG_DB.query_all("SELECT DISTINCT ON (ucid) * FROM #{view_name} ORDER BY ucid, published DESC", as: ChannelVideo) end - videos.sort_by! { |video| video.published }.reverse! + videos.sort_by!(&.published).reverse! else if user.preferences.unseen_only # Only show unwatched @@ -299,15 +299,15 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) case user.preferences.sort when "published - reverse" - videos.sort_by! { |video| video.published } + videos.sort_by!(&.published) when "alphabetically" - videos.sort_by! { |video| video.title } + videos.sort_by!(&.title) when "alphabetically - reverse" - videos.sort_by! { |video| video.title }.reverse! + videos.sort_by!(&.title).reverse! when "channel name" - videos.sort_by! { |video| video.author } + videos.sort_by!(&.author) when "channel name - reverse" - videos.sort_by! { |video| video.author }.reverse! + videos.sort_by!(&.author).reverse! else nil # Ignore end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 0fe949c7..d38a66d8 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -880,7 +880,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ primary_results = player_response.try &.["contents"]?.try &.["twoColumnWatchNextResults"]?.try &.["results"]? .try &.["results"]?.try &.["contents"]? - sentiment_bar = primary_results.try &.as_a.select { |object| object["videoPrimaryInfoRenderer"]? }[0]? + sentiment_bar = primary_results.try &.as_a.select(&.["videoPrimaryInfoRenderer"]?)[0]? .try &.["videoPrimaryInfoRenderer"]? .try &.["sentimentBar"]? .try &.["sentimentBarRenderer"]? @@ -891,11 +891,11 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ params["likes"] = JSON::Any.new(likes) params["dislikes"] = JSON::Any.new(dislikes) - params["descriptionHtml"] = JSON::Any.new(primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? + params["descriptionHtml"] = JSON::Any.new(primary_results.try &.as_a.select(&.["videoSecondaryInfoRenderer"]?)[0]? .try &.["videoSecondaryInfoRenderer"]?.try &.["description"]?.try &.["runs"]? .try &.as_a.try { |t| content_to_comment_html(t).gsub("\n", "
    ") } || "

    ") - metadata = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? + metadata = primary_results.try &.as_a.select(&.["videoSecondaryInfoRenderer"]?)[0]? .try &.["videoSecondaryInfoRenderer"]? .try &.["metadataRowContainer"]? .try &.["metadataRowContainerRenderer"]? @@ -928,7 +928,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end end - author_info = primary_results.try &.as_a.select { |object| object["videoSecondaryInfoRenderer"]? }[0]? + author_info = primary_results.try &.as_a.select(&.["videoSecondaryInfoRenderer"]?)[0]? .try &.["videoSecondaryInfoRenderer"]?.try &.["owner"]?.try &.["videoOwnerRenderer"]? params["authorThumbnail"] = JSON::Any.new(author_info.try &.["thumbnail"]? @@ -1023,13 +1023,13 @@ end def process_video_params(query, preferences) annotations = query["iv_load_policy"]?.try &.to_i? autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } - comments = query["comments"]?.try &.split(",").map { |a| a.downcase } + comments = query["comments"]?.try &.split(",").map(&.downcase) 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 } + preferred_captions = query["subtitles"]?.try &.split(",").map(&.downcase) quality = query["quality"]? quality_dash = query["quality_dash"]? region = query["region"]? diff --git a/src/invidious/yt_backend/proxy.cr b/src/invidious/yt_backend/proxy.cr index 3418d887..c66fd337 100644 --- a/src/invidious/yt_backend/proxy.cr +++ b/src/invidious/yt_backend/proxy.cr @@ -256,7 +256,7 @@ def decrypt_port(p, x) p = p.gsub(/\b\w+\b/, x) p = p.split(";") - p = p.map { |item| item.split("=") } + p = p.map(&.split("=")) mapping = {} of String => Int32 p.each do |item| -- cgit v1.2.3 From e969c1490a9b01173c05d2c3a10df8aec802b51d Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 24 Sep 2021 19:50:56 -0700 Subject: Fix Performance/ChainedCallWithNoBang issues --- src/invidious/helpers/tokens.cr | 4 ++-- src/invidious/jobs/pull_popular_videos_job.cr | 2 +- src/invidious/routes/api/manifest.cr | 2 +- src/invidious/yt_backend/proxy.cr | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index e6d38315..3874799a 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -105,11 +105,11 @@ end def scope_includes_scope(scope, subset) methods, endpoint = scope.split(":") - methods = methods.split(";").map(&.upcase).reject(&.empty?).sort + methods = methods.split(";").map(&.upcase).reject(&.empty?).sort! endpoint = endpoint.downcase subset_methods, subset_endpoint = subset.split(":") - subset_methods = subset_methods.split(";").map(&.upcase).sort + subset_methods = subset_methods.split(";").map(&.upcase).sort! subset_endpoint = subset_endpoint.downcase if methods.empty? diff --git a/src/invidious/jobs/pull_popular_videos_job.cr b/src/invidious/jobs/pull_popular_videos_job.cr index 7a8ab84e..805016fd 100644 --- a/src/invidious/jobs/pull_popular_videos_job.cr +++ b/src/invidious/jobs/pull_popular_videos_job.cr @@ -16,7 +16,7 @@ class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob loop do videos = db.query_all(QUERY, as: ChannelVideo) .sort_by(&.published) - .reverse + .reverse! POPULAR_VIDEOS.set(videos) diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index f8963587..12687ec6 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -47,7 +47,7 @@ module Invidious::Routes::API::Manifest end audio_streams = video.audio_streams - video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse + video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse! manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("MPD", "xmlns": "urn:mpeg:dash:schema:mpd:2011", diff --git a/src/invidious/yt_backend/proxy.cr b/src/invidious/yt_backend/proxy.cr index c66fd337..e150d887 100644 --- a/src/invidious/yt_backend/proxy.cr +++ b/src/invidious/yt_backend/proxy.cr @@ -236,7 +236,7 @@ def get_spys_proxies(country_code = "US") proxies << {ip: ip, port: port, score: score} end - proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse + proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse! return proxies end -- cgit v1.2.3 From dd8c412abc90148b50cebc06546b750898b1fe8a Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 24 Sep 2021 19:57:46 -0700 Subject: Fix Style/IsAFilter issues --- src/invidious/helpers/errors.cr | 2 -- src/invidious/routes/channels.cr | 4 ++-- src/invidious/routes/playlists.cr | 2 +- src/invidious/yt_backend/extractors_utils.cr | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index e1d02563..e5c77fbc 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -132,8 +132,6 @@ def error_redirect_helper(env : HTTP::Server::Context, locale : Hash(String, JSO
  • END_HTML - - return next_step_html else return "" end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index bfcc3edd..619159a2 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -29,7 +29,7 @@ module Invidious::Routes::Channels item.author end end - items = items.select(&.is_a?(SearchPlaylist)).map(&.as(SearchPlaylist)) + items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) items.each(&.author=("")) else sort_options = {"newest", "oldest", "popular"} @@ -57,7 +57,7 @@ module Invidious::Routes::Channels end items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) - items = items.select { |item| item.is_a?(SearchPlaylist) }.map { |item| item.as(SearchPlaylist) } + items = items.select(SearchPlaylist).map { |item| item.as(SearchPlaylist) } items.each(&.author=("")) templated "playlists" diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 5ab15093..13d99946 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -245,7 +245,7 @@ module Invidious::Routes::Playlists if query begin search_query, count, items, operators = process_search_query(query, page, user, region: nil) - videos = items.select { |item| item.is_a? SearchVideo }.map { |item| item.as(SearchVideo) } + videos = items.select(SearchVideo).map { |item| item.as(SearchVideo) } rescue ex videos = [] of SearchVideo count = 0 diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index 97cc0997..add5f488 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -40,7 +40,7 @@ def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : Str target << i end end - return target.select(&.is_a?(SearchVideo)).map(&.as(SearchVideo)) + return target.select(SearchVideo).map(&.as(SearchVideo)) end def extract_selected_tab(tabs) -- cgit v1.2.3 From 1adcac175e357853af1014b9e1bc792e11c7e8de Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Sun, 26 Sep 2021 21:55:15 +0000 Subject: Update src/invidious/routes/channels.cr Co-authored-by: Samantaz Fox --- src/invidious/routes/channels.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 619159a2..13beca17 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -57,7 +57,7 @@ module Invidious::Routes::Channels end items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) - items = items.select(SearchPlaylist).map { |item| item.as(SearchPlaylist) } + items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) items.each(&.author=("")) templated "playlists" -- cgit v1.2.3 From 88ad7c8d8d7728443eabcd48552531f35ff0f065 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Sun, 26 Sep 2021 21:55:25 +0000 Subject: Update src/invidious/routes/playlists.cr Co-authored-by: Samantaz Fox --- src/invidious/routes/playlists.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 13d99946..21126d7e 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -245,7 +245,7 @@ module Invidious::Routes::Playlists if query begin search_query, count, items, operators = process_search_query(query, page, user, region: nil) - videos = items.select(SearchVideo).map { |item| item.as(SearchVideo) } + videos = items.select(SearchVideo).map(&.as(SearchVideo)) rescue ex videos = [] of SearchVideo count = 0 -- cgit v1.2.3 From 575c66efd342af56d3524645f7e3908e7469f56d Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 26 Sep 2021 14:34:05 -0700 Subject: Remove extra dot between [] from ambea copy-pasting. --- src/invidious/channels/about.cr | 2 +- src/invidious/helpers/static_file_handler.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index a72acc72..c87c53e0 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -149,7 +149,7 @@ def get_about_info(ucid, locale) end end end - tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map(&.["tabRenderer"].["title"].as_s.downcase) + tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map(&.["tabRenderer"]["title"].as_s.downcase) end sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? diff --git a/src/invidious/helpers/static_file_handler.cr b/src/invidious/helpers/static_file_handler.cr index 11151874..630c2fd2 100644 --- a/src/invidious/helpers/static_file_handler.cr +++ b/src/invidious/helpers/static_file_handler.cr @@ -173,7 +173,7 @@ module Kemal return end - if @cached_files.sum(&.[1].[:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT + if @cached_files.sum(&.[1][:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT data = Bytes.new(size) File.open(file_path) do |file| file.read(data) -- cgit v1.2.3 From b8f27a42a75169c22c3fec5e24555fffcd29c499 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 26 Sep 2021 14:42:00 -0700 Subject: Optimize assigning of on_response_received_endpoints --- src/invidious/comments.cr | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 3370b088..ffdce000 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -72,8 +72,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) contents = nil - if response["onResponseReceivedEndpoints"]? - on_response_received_endpoints = response["onResponseReceivedEndpoints"] + if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? header = nil on_response_received_endpoints.as_a.each do |item| if item["reloadContinuationItemsCommand"]? -- cgit v1.2.3 From 17e481c10720b273745d222770e2cc86edc608fc Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 26 Sep 2021 14:58:48 -0700 Subject: Reduce block verbosity further --- src/invidious/playlists.cr | 2 +- src/invidious/routes/channels.cr | 4 ++-- src/invidious/yt_backend/proxy.cr | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 70c74eb3..443d19d7 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -51,7 +51,7 @@ struct PlaylistVideo if xml to_xml(auto_generated, xml) else - XML.build do |xml| # Why was this `json`? + XML.build do |xml| to_xml(auto_generated, xml) end end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 13beca17..29748cd0 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -30,7 +30,7 @@ module Invidious::Routes::Channels end end items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) - items.each(&.author=("")) + items.each(&.author = "") else sort_options = {"newest", "oldest", "popular"} sort_by ||= "newest" @@ -58,7 +58,7 @@ module Invidious::Routes::Channels items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) - items.each(&.author=("")) + items.each(&.author = "") templated "playlists" end diff --git a/src/invidious/yt_backend/proxy.cr b/src/invidious/yt_backend/proxy.cr index e150d887..2d0fd4ba 100644 --- a/src/invidious/yt_backend/proxy.cr +++ b/src/invidious/yt_backend/proxy.cr @@ -236,7 +236,7 @@ def get_spys_proxies(country_code = "US") proxies << {ip: ip, port: port, score: score} end - proxies = proxies.sort_by { |proxy| proxy[:score] }.reverse! + proxies = proxies.sort_by!(&.[:score]).reverse! return proxies end -- cgit v1.2.3 From e2bbc9a6fa401fb969130251ea1a67003051a667 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 26 Sep 2021 15:04:33 -0700 Subject: Switch to #sort_by! in pull_popular_videos job --- src/invidious/jobs/pull_popular_videos_job.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/jobs/pull_popular_videos_job.cr b/src/invidious/jobs/pull_popular_videos_job.cr index 805016fd..38de816e 100644 --- a/src/invidious/jobs/pull_popular_videos_job.cr +++ b/src/invidious/jobs/pull_popular_videos_job.cr @@ -15,7 +15,7 @@ class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob def begin loop do videos = db.query_all(QUERY, as: ChannelVideo) - .sort_by(&.published) + .sort_by!(&.published) .reverse! POPULAR_VIDEOS.set(videos) -- cgit v1.2.3 From 88752f32bdf683ab6586d1d81c469a5a3a860016 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 26 Oct 2021 23:09:35 +0200 Subject: Normalize translation key for user prefrerences preferences normalized: - annotations - annotations_subscribed - automatic_instance_redirect - autoplay - captions - comments - continue - continue_autoplay - dark_mode - default_home - extend_desc - feed_menu - listen - local - locale - max_results - notifications_only - player_style - quality - quality_dash - related_videos - show_nick - sort - speed - thin_mode - unseen_only - video_loop - volume - vr_mode --- locales/ar.json | 56 ++++++++++++++++----------------- locales/bn_BD.json | 18 +++++------ locales/cs.json | 50 +++++++++++++++--------------- locales/da.json | 50 +++++++++++++++--------------- locales/de.json | 56 ++++++++++++++++----------------- locales/el.json | 48 ++++++++++++++-------------- locales/en-US.json | 56 ++++++++++++++++----------------- locales/eo.json | 56 ++++++++++++++++----------------- locales/es.json | 56 ++++++++++++++++----------------- locales/eu.json | 20 ++++++------ locales/fa.json | 56 ++++++++++++++++----------------- locales/fi.json | 56 ++++++++++++++++----------------- locales/fr.json | 56 ++++++++++++++++----------------- locales/he.json | 42 ++++++++++++------------- locales/hr.json | 56 ++++++++++++++++----------------- locales/hu-HU.json | 52 +++++++++++++++---------------- locales/id.json | 56 ++++++++++++++++----------------- locales/is.json | 48 ++++++++++++++-------------- locales/it.json | 48 ++++++++++++++-------------- locales/ja.json | 56 ++++++++++++++++----------------- locales/ko.json | 56 ++++++++++++++++----------------- locales/lt.json | 56 ++++++++++++++++----------------- locales/nb-NO.json | 56 ++++++++++++++++----------------- locales/nl.json | 52 +++++++++++++++---------------- locales/pl.json | 56 ++++++++++++++++----------------- locales/pt-BR.json | 56 ++++++++++++++++----------------- locales/pt-PT.json | 56 ++++++++++++++++----------------- locales/pt.json | 56 ++++++++++++++++----------------- locales/ro.json | 48 ++++++++++++++-------------- locales/ru.json | 56 ++++++++++++++++----------------- locales/sk.json | 40 ++++++++++++------------ locales/sr.json | 12 +++---- locales/sr_Cyrl.json | 46 +++++++++++++-------------- locales/sv-SE.json | 54 ++++++++++++++++---------------- locales/tr.json | 56 ++++++++++++++++----------------- locales/uk.json | 48 ++++++++++++++-------------- locales/vi.json | 56 ++++++++++++++++----------------- locales/zh-CN.json | 56 ++++++++++++++++----------------- locales/zh-TW.json | 56 ++++++++++++++++----------------- src/invidious/views/preferences.ecr | 62 ++++++++++++++++++------------------- src/invidious/views/watch.ecr | 2 +- 41 files changed, 1014 insertions(+), 1014 deletions(-) (limited to 'src') diff --git a/locales/ar.json b/locales/ar.json index 4d630653..ea486d84 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -61,38 +61,38 @@ "Google verification code": "رمز تحقق جوجل", "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: ": "التعليقات الإفتراضية: ", + "preferences_video_loop_label": "كرر المقطع المرئيّ دائما: ", + "preferences_autoplay_label": "تشغيل تلقائي: ", + "preferences_continue_label": "شغل المقطع التالي تلقائيًا: ", + "preferences_continue_autoplay_label": "شغل المقطع التالي تلقائيًا: ", + "preferences_listen_label": "تشغيل النسخة السمعية تلقائيًا: ", + "preferences_local_label": "بروكسي المقاطع المرئيّة؟ ", + "preferences_speed_label": "السرعة الإفتراضية: ", + "preferences_quality_label": "الجودة المفضلة للمقاطع: ", + "preferences_volume_label": "صوت المشغل: ", + "preferences_comments_label": "التعليقات الإفتراضية: ", "youtube": "يوتيوب", "reddit": "ريديت", - "Default captions: ": "التسميات التوضيحية الإفتراضية: ", + "preferences_captions_label": "التسميات التوضيحية الإفتراضية: ", "Fallback captions: ": "التسميات التوضيحية الاحتياطيَّة: ", - "Show related videos: ": "اعرض الفيديوهات ذات الصلة: ", - "Show annotations by default: ": "اعرض الملاحظات في الفيديو تلقائيا: ", - "Automatically extend video description: ": "توسيع وصف الفيديو تلقائيا: ", - "Interactive 360 degree videos: ": "مقاطع فيديو تفاعلية ب درجة 360: ", + "preferences_related_videos_label": "اعرض الفيديوهات ذات الصلة: ", + "preferences_annotations_label": "اعرض الملاحظات في الفيديو تلقائيا: ", + "preferences_extend_desc_label": "توسيع وصف الفيديو تلقائيا: ", + "preferences_vr_mode_label": "مقاطع فيديو تفاعلية ب درجة 360: ", "Visual preferences": "التفضيلات المرئية", - "Player style: ": "شكل مشغل الفيديوهات: ", + "preferences_player_style_label": "شكل مشغل الفيديوهات: ", "Dark mode: ": "الوضع الليلى: ", - "Theme: ": "المظهر: ", + "preferences_dark_mode_label": "المظهر: ", "dark": "غامق (اسود)", "light": "فاتح (ابيض)", - "Thin mode: ": "الوضع الخفيف: ", + "preferences_thin_mode_label": "الوضع الخفيف: ", "Miscellaneous preferences": "تفضيلات متنوعة", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ", "Subscription preferences": "تفضيلات الإشتراك", - "Show annotations by default for subscribed channels: ": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ", + "preferences_annotations_subscribed_label": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ", "Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ", - "Number of videos shown in feed: ": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ", - "Sort videos by: ": "ترتيب الفيديو ب: ", + "preferences_max_results_label": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ", + "preferences_sort_label": "ترتيب الفيديو ب: ", "published": "احدث فيديو", "published - reverse": "احدث فيديو - عكسى", "alphabetically": "ترتيب ابجدى", @@ -101,8 +101,8 @@ "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): ": "إظهار الإشعارات فقط (إذا كان هناك أي): ", + "preferences_unseen_only_label": "فقط اظهر الذى لم يتم رؤيتة: ", + "preferences_notifications_only_label": "إظهار الإشعارات فقط (إذا كان هناك أي): ", "Enable web notifications": "تفعيل إشعارات المتصفح", "`x` uploaded a video": "`x` رفع فيديو", "`x` is live": "`x` فى بث مباشر", @@ -115,9 +115,9 @@ "Watch history": "سجل المشاهدة", "Delete account": "حذف الحساب", "Administrator preferences": "إعدادات المدير", - "Default homepage: ": "الصفحة الرئيسية الافتراضية ", - "Feed menu: ": "قائمة التدفقات: ", - "Show nickname on top: ": "إظهار اللقب في الأعلى: ", + "preferences_default_home_label": "الصفحة الرئيسية الافتراضية ", + "preferences_feed_menu_label": "قائمة التدفقات: ", + "preferences_show_nick_label": "إظهار اللقب في الأعلى: ", "Top enabled: ": "تفعيل 'الأفضل' ؟ ", "CAPTCHA enabled: ": "تفعيل الكابتشا: ", "Login enabled: ": "تفعيل الولوج: ", @@ -374,7 +374,7 @@ "Top": "الأفضل", "About": "حول", "Rating: ": "التقييم: ", - "Language: ": "اللغة: ", + "preferences_locale_label": "اللغة: ", "View as playlist": "عرض كا قائمة التشغيل", "Default": "الكل", "Music": "الاغانى", diff --git a/locales/bn_BD.json b/locales/bn_BD.json index dbf2a020..d59e6393 100644 --- a/locales/bn_BD.json +++ b/locales/bn_BD.json @@ -61,13 +61,13 @@ "Google verification code": "গুগল যাচাইকরণ কোড", "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: ": "প্লেয়ার শব্দের মাত্রা: " + "preferences_video_loop_label": "সর্বদা লুপ: ", + "preferences_autoplay_label": "স্বয়ংক্রিয় চালু: ", + "preferences_continue_label": "ডিফল্টভাবে পরবর্তী চালাও: ", + "preferences_continue_autoplay_label": "পরবর্তী ভিডিও স্বয়ংক্রিয়ভাবে চালাও: ", + "preferences_listen_label": "সহজাতভাবে শোনো: ", + "preferences_local_label": "ভিডিও প্রক্সি করো: ", + "preferences_speed_label": "সহজাত গতি: ", + "preferences_quality_label": "পছন্দের ভিডিও মান: ", + "preferences_volume_label": "প্লেয়ার শব্দের মাত্রা: " } diff --git a/locales/cs.json b/locales/cs.json index 3e479b60..9921566d 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -61,35 +61,35 @@ "Google verification code": "Verifikační číslo Google", "Preferences": "Nastavení", "Player preferences": "Nastavení přehravače", - "Always loop: ": "Vždy opakovat: ", - "Autoplay: ": "Automatické přehrávání: ", - "Play next by default: ": "Přehrát další ve výchozím stavu: ", - "Autoplay next video: ": "Automaticky přehrát další video: ", - "Listen by default: ": "Poslouchat ve výchozím nastavení: ", - "Proxy videos: ": "Video přes proxy: ", - "Default speed: ": "Základní Rychlost: ", - "Preferred video quality: ": "Preferovaná kvalita videa: ", - "Player volume: ": "Hlasitost přehrávače: ", - "Default comments: ": "Předpřipravené komentáře: ", + "preferences_video_loop_label": "Vždy opakovat: ", + "preferences_autoplay_label": "Automatické přehrávání: ", + "preferences_continue_label": "Přehrát další ve výchozím stavu: ", + "preferences_continue_autoplay_label": "Automaticky přehrát další video: ", + "preferences_listen_label": "Poslouchat ve výchozím nastavení: ", + "preferences_local_label": "Video přes proxy: ", + "preferences_speed_label": "Základní Rychlost: ", + "preferences_quality_label": "Preferovaná kvalita videa: ", + "preferences_volume_label": "Hlasitost přehrávače: ", + "preferences_comments_label": "Předpřipravené komentáře: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "Standartní Titulky: ", + "preferences_captions_label": "Standartní Titulky: ", "Fallback captions: ": "Záložní titulky: ", - "Show related videos: ": "Zobrazit podobné videa: ", - "Show annotations by default: ": "Zobrazovat poznámky ve výchozím nastavení: ", - "Automatically extend video description: ": "Rozšířit automaticky popis u videa: ", + "preferences_related_videos_label": "Zobrazit podobné videa: ", + "preferences_annotations_label": "Zobrazovat poznámky ve výchozím nastavení: ", + "preferences_extend_desc_label": "Rozšířit automaticky popis u videa: ", "Visual preferences": "Nastavení vzhledu", - "Player style: ": "Styl přehrávače ", + "preferences_player_style_label": "Styl přehrávače ", "Dark mode: ": "Tmavý režim ", - "Theme: ": "Vzhled: ", + "preferences_dark_mode_label": "Vzhled: ", "dark": "tmavý", "light": "světlý", - "Thin mode: ": "Kompaktní režim: ", + "preferences_thin_mode_label": "Kompaktní režim: ", "Subscription preferences": "Nastavení předplatných", - "Show annotations by default for subscribed channels: ": "Ve výchozím nastavení zobrazovat poznámky u odebíraných kanálů: ", + "preferences_annotations_subscribed_label": "Ve výchozím nastavení zobrazovat poznámky u odebíraných kanálů: ", "Redirect homepage to feed: ": "Přesměrovávat domovskou stránku na informační kanál: ", - "Number of videos shown in feed: ": "Počet videí zobrazovaných v informačním kanále: ", - "Sort videos by: ": "Roztřídit videa podle: ", + "preferences_max_results_label": "Počet videí zobrazovaných v informačním kanále: ", + "preferences_sort_label": "Roztřídit videa podle: ", "published": "publikováno", "published - reverse": "podle publikování - obrátit", "alphabetically": "podle abecedy", @@ -98,8 +98,8 @@ "channel name - reverse": "podle jména kanálu - převrátit", "Only show latest video from channel: ": "Jenom zobrazit nejnovjejší video z kanálu: ", "Only show latest unwatched video from channel: ": "Zobrazit jen nejnovější nezhlédnuté video z daného kanálu: ", - "Only show unwatched: ": "Zobrazit jen již nezhlédnuté: ", - "Only show notifications (if there are any): ": "Zobrazit pouze upozornění (pokud nějaká jsou): ", + "preferences_unseen_only_label": "Zobrazit jen již nezhlédnuté: ", + "preferences_notifications_only_label": "Zobrazit pouze upozornění (pokud nějaká jsou): ", "Enable web notifications": "Povolit webové upozornění", "`x` uploaded a video": "`x` nahrál(a) video", "`x` is live": "`x` je živě", @@ -112,8 +112,8 @@ "Watch history": "Historie Sledování", "Delete account": "Smazat Účet", "Administrator preferences": "Administrátorská nastavení", - "Default homepage: ": "Základní domovská stránka: ", - "Feed menu: ": "Menu doporučených: ", + "preferences_default_home_label": "Základní domovská stránka: ", + "preferences_feed_menu_label": "Menu doporučených: ", "CAPTCHA enabled: ": "CAPTCHA povolen: ", "Login enabled: ": "Přihlášení povoleno: ", "Registration enabled: ": "Registrace povolena ", @@ -272,7 +272,7 @@ "Popular": "Populární", "About": "Informace", "Rating: ": "Hodnocení: ", - "Language: ": "Jazyk: ", + "preferences_locale_label": "Jazyk: ", "Default": "Výchozí", "Music": "Hudba", "Gaming": "Hry", diff --git a/locales/da.json b/locales/da.json index 45454132..54513db7 100644 --- a/locales/da.json +++ b/locales/da.json @@ -61,36 +61,36 @@ "Google verification code": "Google-verifikationskode", "Preferences": "Præferencer", "Player preferences": "Afspillerindstillinger", - "Always loop: ": "Altid gentag: ", - "Autoplay: ": "Auto afspil: ", - "Play next by default: ": "Afspil næste som standard: ", - "Autoplay next video: ": "Auto afspil næste video: ", - "Listen by default: ": "Lyt som standard: ", - "Proxy videos: ": "Proxy videoer: ", - "Default speed: ": "Standard hastighed: ", - "Preferred video quality: ": "Foretrukken video kvalitet: ", - "Player volume: ": "Lydstyrke: ", - "Default comments: ": "Standard kommentarer: ", + "preferences_video_loop_label": "Altid gentag: ", + "preferences_autoplay_label": "Auto afspil: ", + "preferences_continue_label": "Afspil næste som standard: ", + "preferences_continue_autoplay_label": "Auto afspil næste video: ", + "preferences_listen_label": "Lyt som standard: ", + "preferences_local_label": "Proxy videoer: ", + "preferences_speed_label": "Standard hastighed: ", + "preferences_quality_label": "Foretrukken video kvalitet: ", + "preferences_volume_label": "Lydstyrke: ", + "preferences_comments_label": "Standard kommentarer: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "Standard undertekster: ", + "preferences_captions_label": "Standard undertekster: ", "Fallback captions: ": "Alternative undertekster: ", - "Show related videos: ": "Vis relaterede videoer: ", - "Show annotations by default: ": "Vis annotationer som standard: ", - "Automatically extend video description: ": "Automatisk udvid videoens beskrivelse: ", - "Interactive 360 degree videos: ": "Interaktiv 360 graders videoer: ", + "preferences_related_videos_label": "Vis relaterede videoer: ", + "preferences_annotations_label": "Vis annotationer som standard: ", + "preferences_extend_desc_label": "Automatisk udvid videoens beskrivelse: ", + "preferences_vr_mode_label": "Interaktiv 360 graders videoer: ", "Visual preferences": "Visuelle præferencer", - "Player style: ": "Afspiller stil: ", + "preferences_player_style_label": "Afspiller stil: ", "Dark mode: ": "Mørk tilstand: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "mørk", "light": "lys", - "Thin mode: ": "Tynd tilstand: ", + "preferences_thin_mode_label": "Tynd tilstand: ", "Subscription preferences": "Abonnements præferencer", - "Show annotations by default for subscribed channels: ": "Vis annotationer som standard for abonnerede kanaler: ", + "preferences_annotations_subscribed_label": "Vis annotationer som standard for abonnerede kanaler: ", "Redirect homepage to feed: ": "Omdiriger startside til feed: ", - "Number of videos shown in feed: ": "Antal videoer vist i feed: ", - "Sort videos by: ": "Sorter videoer efter: ", + "preferences_max_results_label": "Antal videoer vist i feed: ", + "preferences_sort_label": "Sorter videoer efter: ", "published": "offentliggjort", "published - reverse": "offentliggjort - omvendt", "alphabetically": "alfabetisk", @@ -99,8 +99,8 @@ "channel name - reverse": "kanalnavn - omvendt", "Only show latest video from channel: ": "Vis kun seneste video fra kanal: ", "Only show latest unwatched video from channel: ": "Vis kun seneste usete video fra kanal: ", - "Only show unwatched: ": "Vis kun usete: ", - "Only show notifications (if there are any): ": "Vis kun notifikationer (hvis der er nogle): ", + "preferences_unseen_only_label": "Vis kun usete: ", + "preferences_notifications_only_label": "Vis kun notifikationer (hvis der er nogle): ", "Enable web notifications": "Aktiver webnotifikationer", "`x` uploaded a video": "`x` uploadede en video", "`x` is live": "`x` er live", @@ -113,8 +113,8 @@ "Watch history": "Afspilningshistorik", "Delete account": "Slet konto", "Administrator preferences": "Administrator præferencer", - "Default homepage: ": "Standard startside: ", - "Feed menu: ": "Feed menu: ", + "preferences_default_home_label": "Standard startside: ", + "preferences_feed_menu_label": "Feed menu: ", "Top enabled: ": "Top aktiveret: ", "CAPTCHA enabled: ": "CAPTCHA aktiveret: ", "Login enabled: ": "Login aktiveret: ", diff --git a/locales/de.json b/locales/de.json index c5a6565b..256ef41a 100644 --- a/locales/de.json +++ b/locales/de.json @@ -61,38 +61,38 @@ "Google verification code": "Google-Bestätigungscode", "Preferences": "Einstellungen", "Player preferences": "Wiedergabeeinstellungen", - "Always loop: ": "Immer wiederholen: ", - "Autoplay: ": "Automatisch abspielen: ", - "Play next by default: ": "Immer automatisch nächstes Video spielen: ", - "Autoplay next video: ": "nächstes Video automatisch abspielen: ", - "Listen by default: ": "Nur Ton als Standard: ", - "Proxy videos: ": "Proxy-Videos: ", - "Default speed: ": "Standardgeschwindigkeit: ", - "Preferred video quality: ": "Bevorzugte Videoqualität: ", - "Player volume: ": "Wiedergabelautstärke: ", - "Default comments: ": "Standardkommentare: ", + "preferences_video_loop_label": "Immer wiederholen: ", + "preferences_autoplay_label": "Automatisch abspielen: ", + "preferences_continue_label": "Immer automatisch nächstes Video spielen: ", + "preferences_continue_autoplay_label": "nächstes Video automatisch abspielen: ", + "preferences_listen_label": "Nur Ton als Standard: ", + "preferences_local_label": "Proxy-Videos: ", + "preferences_speed_label": "Standardgeschwindigkeit: ", + "preferences_quality_label": "Bevorzugte Videoqualität: ", + "preferences_volume_label": "Wiedergabelautstärke: ", + "preferences_comments_label": "Standardkommentare: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "Standarduntertitel: ", + "preferences_captions_label": "Standarduntertitel: ", "Fallback captions: ": "Ersatzuntertitel: ", - "Show related videos: ": "Ähnliche Videos anzeigen? ", - "Show annotations by default: ": "Standardmäßig Anmerkungen anzeigen? ", - "Automatically extend video description: ": "Videobeschreibung automatisch erweitern: ", - "Interactive 360 degree videos: ": "Interaktive 360 Grad Videos: ", + "preferences_related_videos_label": "Ähnliche Videos anzeigen? ", + "preferences_annotations_label": "Standardmäßig Anmerkungen anzeigen? ", + "preferences_extend_desc_label": "Videobeschreibung automatisch erweitern: ", + "preferences_vr_mode_label": "Interaktive 360 Grad Videos: ", "Visual preferences": "Anzeigeeinstellungen", - "Player style: ": "Abspielgeräterstil: ", + "preferences_player_style_label": "Abspielgeräterstil: ", "Dark mode: ": "Nachtmodus: ", - "Theme: ": "Modus: ", + "preferences_dark_mode_label": "Modus: ", "dark": "Nachtmodus", "light": "heller Modus", - "Thin mode: ": "Schlanker Modus: ", + "preferences_thin_mode_label": "Schlanker Modus: ", "Miscellaneous preferences": "Sonstige Einstellungen", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatische Instanzweiterleitung (über redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Automatische Instanzweiterleitung (über redirect.invidious.io): ", "Subscription preferences": "Abonnementeinstellungen", - "Show annotations by default for subscribed channels: ": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ", + "preferences_annotations_subscribed_label": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ", "Redirect homepage to feed: ": "Startseite zu Feed umleiten: ", - "Number of videos shown in feed: ": "Anzahl von Videos die im Feed angezeigt werden: ", - "Sort videos by: ": "Videos sortieren nach: ", + "preferences_max_results_label": "Anzahl von Videos die im Feed angezeigt werden: ", + "preferences_sort_label": "Videos sortieren nach: ", "published": "veröffentlicht", "published - reverse": "veröffentlicht - invertiert", "alphabetically": "alphabetisch", @@ -101,8 +101,8 @@ "channel name - reverse": "Kanalname - invertiert", "Only show latest video from channel: ": "Nur neueste Videos des Kanals anzeigen: ", "Only show latest unwatched video from channel: ": "Nur neueste ungesehene Videos des Kanals anzeigen: ", - "Only show unwatched: ": "Nur ungesehene anzeigen: ", - "Only show notifications (if there are any): ": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ", + "preferences_unseen_only_label": "Nur ungesehene anzeigen: ", + "preferences_notifications_only_label": "Nur Benachrichtigungen anzeigen (wenn es welche gibt): ", "Enable web notifications": "Webbenachrichtigungen aktivieren", "`x` uploaded a video": "`x` hat ein Video hochgeladen", "`x` is live": "`x` ist live", @@ -115,9 +115,9 @@ "Watch history": "Verlauf", "Delete account": "Account löschen", "Administrator preferences": "Administrator-Einstellungen", - "Default homepage: ": "Standard-Startseite: ", - "Feed menu: ": "Feed-Menü: ", - "Show nickname on top: ": "Nutzernamen oben anzeigen: ", + "preferences_default_home_label": "Standard-Startseite: ", + "preferences_feed_menu_label": "Feed-Menü: ", + "preferences_show_nick_label": "Nutzernamen oben anzeigen: ", "Top enabled: ": "Top aktiviert? ", "CAPTCHA enabled: ": "CAPTCHA aktiviert? ", "Login enabled: ": "Anmeldung aktiviert: ", @@ -374,7 +374,7 @@ "Top": "Top", "About": "Über", "Rating: ": "Bewertung: ", - "Language: ": "Sprache: ", + "preferences_locale_label": "Sprache: ", "View as playlist": "Als Wiedergabeliste anzeigen", "Default": "Standard", "Music": "Musik", diff --git a/locales/el.json b/locales/el.json index 99b21fb8..f3f13c1d 100644 --- a/locales/el.json +++ b/locales/el.json @@ -61,34 +61,34 @@ "Google verification code": "Κωδικός επαλήθευσης Google", "Preferences": "Προτιμήσεις", "Player preferences": "Προτιμήσεις αναπαραγωγής", - "Always loop: ": "Αυτόματη επανάληψη: ", - "Autoplay: ": "Αυτόματη αναπαραγωγή: ", - "Play next by default: ": "Αναπαραγωγή επόμενου: ", - "Autoplay next video: ": "Αυτόματη αναπαραγωγή επόμενου: ", - "Listen by default: ": "Φόρτωση μόνο ήχου: ", - "Proxy videos: ": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ", - "Default speed: ": "Προεπιλεγμένη ταχύτητα: ", - "Preferred video quality: ": "Προτιμώμενη ανάλυση: ", - "Player volume: ": "Ένταση αναπαραγωγής: ", - "Default comments: ": "Προεπιλεγμένα σχόλια: ", + "preferences_video_loop_label": "Αυτόματη επανάληψη: ", + "preferences_autoplay_label": "Αυτόματη αναπαραγωγή: ", + "preferences_continue_label": "Αναπαραγωγή επόμενου: ", + "preferences_continue_autoplay_label": "Αυτόματη αναπαραγωγή επόμενου: ", + "preferences_listen_label": "Φόρτωση μόνο ήχου: ", + "preferences_local_label": "Αναπαραγωγή με διακομιστή μεσολάβησης (proxy): ", + "preferences_speed_label": "Προεπιλεγμένη ταχύτητα: ", + "preferences_quality_label": "Προτιμώμενη ανάλυση: ", + "preferences_volume_label": "Ένταση αναπαραγωγής: ", + "preferences_comments_label": "Προεπιλεγμένα σχόλια: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "Προεπιλεγμένοι υπότιτλοι: ", + "preferences_captions_label": "Προεπιλεγμένοι υπότιτλοι: ", "Fallback captions: ": "Εναλλακτικοί υπότιτλοι: ", - "Show related videos: ": "Προβολή σχετικών βίντεο; ", - "Show annotations by default: ": "Αυτόματη προβολή σημειώσεων: ", + "preferences_related_videos_label": "Προβολή σχετικών βίντεο; ", + "preferences_annotations_label": "Αυτόματη προβολή σημειώσεων: ", "Visual preferences": "Προτιμήσεις εμφάνισης", - "Player style: ": "Τεχνοτροπία της συσκευής αναπαραγωγης: ", + "preferences_player_style_label": "Τεχνοτροπία της συσκευής αναπαραγωγης: ", "Dark mode: ": "Σκοτεινή λειτουργία: ", - "Theme: ": "Θέμα: ", + "preferences_dark_mode_label": "Θέμα: ", "dark": "σκοτεινό", "light": "φωτεινό", - "Thin mode: ": "Ελαφριά λειτουργία: ", + "preferences_thin_mode_label": "Ελαφριά λειτουργία: ", "Subscription preferences": "Προτιμήσεις συνδρομών", - "Show annotations by default for subscribed channels: ": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ", + "preferences_annotations_subscribed_label": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ", "Redirect homepage to feed: ": "Ανακατεύθυνση αρχικής στη ροή συνδρομών: ", - "Number of videos shown in feed: ": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ", - "Sort videos by: ": "Ταξινόμηση ανά: ", + "preferences_max_results_label": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ", + "preferences_sort_label": "Ταξινόμηση ανά: ", "published": "ημερομηνία δημοσίευσης", "published - reverse": "ημερομηνία δημοσίευσης - ανάποδα", "alphabetically": "αλφαβητικά", @@ -97,8 +97,8 @@ "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): ": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ", + "preferences_unseen_only_label": "Προβολή μόνο μη-προβεβλημένων: ", + "preferences_notifications_only_label": "Προβολή μόνο ειδοποιήσεων (αν υπάρχουν): ", "Enable web notifications": "Ενεργοποίηση ειδοποιήσεων δικτύου", "`x` uploaded a video": "`x` κοινοποίησε ένα βίντεο", "`x` is live": "`x` κάνει live", @@ -111,8 +111,8 @@ "Watch history": "Ιστορικό προβολής", "Delete account": "Διαγραφή λογαριασμού", "Administrator preferences": "Προτιμήσεις διαχειριστή", - "Default homepage: ": "Προεπιλεγμένη αρχική: ", - "Feed menu: ": "Μενού ροής συνδρομών: ", + "preferences_default_home_label": "Προεπιλεγμένη αρχική: ", + "preferences_feed_menu_label": "Μενού ροής συνδρομών: ", "Top enabled: ": "Ενεργοποίηση κορυφαίων; ", "CAPTCHA enabled: ": "Ενεργοποίηση CAPTCHA; ", "Login enabled: ": "Ενεργοποίηση σύνδεσης; ", @@ -363,7 +363,7 @@ "Top": "Κορυφαία", "About": "Σχετικά", "Rating: ": "Aξιολόγηση: ", - "Language: ": "Γλώσσα: ", + "preferences_locale_label": "Γλώσσα: ", "View as playlist": "Προβολή ως λίστα αναπαραγωγής", "Default": "Προεπιλογή", "Music": "Μουσική", diff --git a/locales/en-US.json b/locales/en-US.json index 230d96ad..89b5d0ae 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -61,38 +61,38 @@ "Google verification code": "Google verification code", "Preferences": "Preferences", "Player preferences": "Player preferences", - "Always loop: ": "Always loop: ", - "Autoplay: ": "Autoplay: ", - "Play next by default: ": "Play next by default: ", - "Autoplay next video: ": "Autoplay next video: ", - "Listen by default: ": "Listen by default: ", - "Proxy videos: ": "Proxy videos: ", - "Default speed: ": "Default speed: ", - "Preferred video quality: ": "Preferred video quality: ", - "Player volume: ": "Player volume: ", - "Default comments: ": "Default comments: ", + "preferences_video_loop_label": "Always loop: ", + "preferences_autoplay_label": "Autoplay: ", + "preferences_continue_label": "Play next by default: ", + "preferences_continue_autoplay_label": "Autoplay next video: ", + "preferences_listen_label": "Listen by default: ", + "preferences_local_label": "Proxy videos: ", + "preferences_speed_label": "Default speed: ", + "preferences_quality_label": "Preferred video quality: ", + "preferences_volume_label": "Player volume: ", + "preferences_comments_label": "Default comments: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Default captions: ", + "preferences_captions_label": "Default captions: ", "Fallback captions: ": "Fallback captions: ", - "Show related videos: ": "Show related videos: ", - "Show annotations by default: ": "Show annotations by default: ", - "Automatically extend video description: ": "Automatically extend video description: ", - "Interactive 360 degree videos: ": "Interactive 360 degree videos: ", + "preferences_related_videos_label": "Show related videos: ", + "preferences_annotations_label": "Show annotations by default: ", + "preferences_extend_desc_label": "Automatically extend video description: ", + "preferences_vr_mode_label": "Interactive 360 degree videos: ", "Visual preferences": "Visual preferences", - "Player style: ": "Player style: ", + "preferences_player_style_label": "Player style: ", "Dark mode: ": "Dark mode: ", - "Theme: ": "Theme: ", + "preferences_dark_mode_label": "Theme: ", "dark": "dark", "light": "light", - "Thin mode: ": "Thin mode: ", + "preferences_thin_mode_label": "Thin mode: ", "Miscellaneous preferences": "Miscellaneous preferences", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automaticatic instance redirection (fallback to redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Automaticatic instance redirection (fallback to redirect.invidious.io): ", "Subscription preferences": "Subscription preferences", - "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", + "preferences_annotations_subscribed_label": "Show annotations by default for subscribed channels? ", "Redirect homepage to feed: ": "Redirect homepage to feed: ", - "Number of videos shown in feed: ": "Number of videos shown in feed: ", - "Sort videos by: ": "Sort videos by: ", + "preferences_max_results_label": "Number of videos shown in feed: ", + "preferences_sort_label": "Sort videos by: ", "published": "published", "published - reverse": "published - reverse", "alphabetically": "alphabetically", @@ -101,8 +101,8 @@ "channel name - reverse": "channel name - reverse", "Only show latest video from channel: ": "Only show latest video from channel: ", "Only show latest unwatched video from channel: ": "Only show latest unwatched video from channel: ", - "Only show unwatched: ": "Only show unwatched: ", - "Only show notifications (if there are any): ": "Only show notifications (if there are any): ", + "preferences_unseen_only_label": "Only show unwatched: ", + "preferences_notifications_only_label": "Only show notifications (if there are any): ", "Enable web notifications": "Enable web notifications", "`x` uploaded a video": "`x` uploaded a video", "`x` is live": "`x` is live", @@ -115,9 +115,9 @@ "Watch history": "Watch history", "Delete account": "Delete account", "Administrator preferences": "Administrator preferences", - "Default homepage: ": "Default homepage: ", - "Feed menu: ": "Feed menu: ", - "Show nickname on top: ": "Show nickname on top: ", + "preferences_default_home_label": "Default homepage: ", + "preferences_feed_menu_label": "Feed menu: ", + "preferences_show_nick_label": "Show nickname on top: ", "Top enabled: ": "Top enabled: ", "CAPTCHA enabled: ": "CAPTCHA enabled: ", "Login enabled: ": "Login enabled: ", @@ -374,7 +374,7 @@ "Top": "Top", "About": "About", "Rating: ": "Rating: ", - "Language: ": "Language: ", + "preferences_locale_label": "Language: ", "View as playlist": "View as playlist", "Default": "Default", "Music": "Music", diff --git a/locales/eo.json b/locales/eo.json index badedfd5..e6f0b3be 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -61,38 +61,38 @@ "Google verification code": "Kontrolkodo de Google", "Preferences": "Agordoj", "Player preferences": "Spektilaj agordoj", - "Always loop: ": "Ĉiam ripeti: ", - "Autoplay: ": "Aŭtomate ludi: ", - "Play next by default: ": "Ludi sekvan defaŭlte: ", - "Autoplay next video: ": "Aŭtomate ludi sekvan filmeton: ", - "Listen by default: ": "Aŭskulti defaŭlte: ", - "Proxy videos: ": "Ĉu uzi prokuran servilon por filmetojn? ", - "Default speed: ": "Defaŭlta rapido: ", - "Preferred video quality: ": "Preferita filmetkvalito: ", - "Player volume: ": "Ludila sonforteco: ", - "Default comments: ": "Defaŭltaj komentoj: ", + "preferences_video_loop_label": "Ĉiam ripeti: ", + "preferences_autoplay_label": "Aŭtomate ludi: ", + "preferences_continue_label": "Ludi sekvan defaŭlte: ", + "preferences_continue_autoplay_label": "Aŭtomate ludi sekvan filmeton: ", + "preferences_listen_label": "Aŭskulti defaŭlte: ", + "preferences_local_label": "Ĉu uzi prokuran servilon por filmetojn? ", + "preferences_speed_label": "Defaŭlta rapido: ", + "preferences_quality_label": "Preferita filmetkvalito: ", + "preferences_volume_label": "Ludila sonforteco: ", + "preferences_comments_label": "Defaŭltaj komentoj: ", "youtube": "JuTubo", "reddit": "Reddit", - "Default captions: ": "Defaŭltaj subtekstoj: ", + "preferences_captions_label": "Defaŭltaj subtekstoj: ", "Fallback captions: ": "Retrodefaŭltaj subtekstoj: ", - "Show related videos: ": "Ĉu montri rilatajn filmetojn? ", - "Show annotations by default: ": "Ĉu montri prinotojn defaŭlte? ", - "Automatically extend video description: ": "Aŭtomate etendi priskribon de filmeto: ", - "Interactive 360 degree videos: ": "Interagaj 360-gradaj filmetoj: ", + "preferences_related_videos_label": "Ĉu montri rilatajn filmetojn? ", + "preferences_annotations_label": "Ĉu montri prinotojn defaŭlte? ", + "preferences_extend_desc_label": "Aŭtomate etendi priskribon de filmeto: ", + "preferences_vr_mode_label": "Interagaj 360-gradaj filmetoj: ", "Visual preferences": "Vidaj preferoj", - "Player style: ": "Ludila stilo: ", + "preferences_player_style_label": "Ludila stilo: ", "Dark mode: ": "Malhela reĝimo: ", - "Theme: ": "Etoso: ", + "preferences_dark_mode_label": "Etoso: ", "dark": "malhela", "light": "hela", - "Thin mode: ": "Maldika reĝimo: ", + "preferences_thin_mode_label": "Maldika reĝimo: ", "Miscellaneous preferences": "Aliaj agordoj", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Aŭtomata alidirektado de instalaĵo (retropaŝo al redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Aŭtomata alidirektado de instalaĵo (retropaŝo al redirect.invidious.io): ", "Subscription preferences": "Abonaj agordoj", - "Show annotations by default for subscribed channels: ": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", + "preferences_annotations_subscribed_label": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", "Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ", - "Number of videos shown in feed: ": "Nombro da filmetoj montritaj en fluo: ", - "Sort videos by: ": "Ordi filmetojn per: ", + "preferences_max_results_label": "Nombro da filmetoj montritaj en fluo: ", + "preferences_sort_label": "Ordi filmetojn per: ", "published": "publikigo", "published - reverse": "publitigo - renverse", "alphabetically": "alfabete", @@ -101,8 +101,8 @@ "channel name - reverse": "kanala nombro - renverse", "Only show latest video from channel: ": "Nur montri pli novan filmeton el kanalo: ", "Only show latest unwatched video from channel: ": "Nur montri pli novan malviditan filmeton el kanalo: ", - "Only show unwatched: ": "Nur montri malviditajn: ", - "Only show notifications (if there are any): ": "Nur montri sciigojn (se estas): ", + "preferences_unseen_only_label": "Nur montri malviditajn: ", + "preferences_notifications_only_label": "Nur montri sciigojn (se estas): ", "Enable web notifications": "Ebligi retejajn sciigojn", "`x` uploaded a video": "`x` alŝutis filmeton", "`x` is live": "`x` estas nuna", @@ -115,9 +115,9 @@ "Watch history": "Vidohistorio", "Delete account": "Forigi konton", "Administrator preferences": "Agordoj de administranto", - "Default homepage: ": "Defaŭlta hejmpaĝo: ", - "Feed menu: ": "Flua menuo: ", - "Show nickname on top: ": "Montri kromnomon supre: ", + "preferences_default_home_label": "Defaŭlta hejmpaĝo: ", + "preferences_feed_menu_label": "Flua menuo: ", + "preferences_show_nick_label": "Montri kromnomon supre: ", "Top enabled: ": "Ĉu pli bonaj ŝaltitaj? ", "CAPTCHA enabled: ": "Ĉu CAPTCHA ŝaltita? ", "Login enabled: ": "Ĉu ensaluto aktivita? ", @@ -374,7 +374,7 @@ "Top": "Supraj", "About": "Pri", "Rating: ": "Takso: ", - "Language: ": "Lingvo: ", + "preferences_locale_label": "Lingvo: ", "View as playlist": "Vidi kiel ludlisto", "Default": "Defaŭlte", "Music": "Muziko", diff --git a/locales/es.json b/locales/es.json index 52eb6566..fb8f2baf 100644 --- a/locales/es.json +++ b/locales/es.json @@ -61,38 +61,38 @@ "Google verification code": "Código de verificación de Google", "Preferences": "Preferencias", "Player preferences": "Preferencias del reproductor", - "Always loop: ": "Repetir siempre: ", - "Autoplay: ": "Reproducción automática: ", - "Play next by default: ": "Reproducir siguiente por defecto: ", - "Autoplay next video: ": "Reproducir automáticamente el vídeo siguiente: ", - "Listen by default: ": "Activar el sonido por defecto: ", - "Proxy videos: ": "¿Usar un proxy para los vídeos? ", - "Default speed: ": "Velocidad por defecto: ", - "Preferred video quality: ": "Calidad de vídeo preferida: ", - "Player volume: ": "Volumen del reproductor: ", - "Default comments: ": "Comentarios por defecto: ", + "preferences_video_loop_label": "Repetir siempre: ", + "preferences_autoplay_label": "Reproducción automática: ", + "preferences_continue_label": "Reproducir siguiente por defecto: ", + "preferences_continue_autoplay_label": "Reproducir automáticamente el vídeo siguiente: ", + "preferences_listen_label": "Activar el sonido por defecto: ", + "preferences_local_label": "¿Usar un proxy para los vídeos? ", + "preferences_speed_label": "Velocidad por defecto: ", + "preferences_quality_label": "Calidad de vídeo preferida: ", + "preferences_volume_label": "Volumen del reproductor: ", + "preferences_comments_label": "Comentarios por defecto: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Subtítulos por defecto: ", + "preferences_captions_label": "Subtítulos por defecto: ", "Fallback captions: ": "Subtítulos alternativos: ", - "Show related videos: ": "¿Mostrar vídeos relacionados? ", - "Show annotations by default: ": "¿Mostrar anotaciones por defecto? ", - "Automatically extend video description: ": "Extender automáticamente la descripción del vídeo: ", - "Interactive 360 degree videos: ": "Vídeos interactivos de 360 grados: ", + "preferences_related_videos_label": "¿Mostrar vídeos relacionados? ", + "preferences_annotations_label": "¿Mostrar anotaciones por defecto? ", + "preferences_extend_desc_label": "Extender automáticamente la descripción del vídeo: ", + "preferences_vr_mode_label": "Vídeos interactivos de 360 grados: ", "Visual preferences": "Preferencias visuales", - "Player style: ": "Estilo de reproductor: ", + "preferences_player_style_label": "Estilo de reproductor: ", "Dark mode: ": "Modo oscuro: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "oscuro", "light": "claro", - "Thin mode: ": "Modo compacto: ", + "preferences_thin_mode_label": "Modo compacto: ", "Miscellaneous preferences": "Preferencias misceláneas", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirección automática de instancia (segunda opción a redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Redirección automática de instancia (segunda opción a redirect.invidious.io): ", "Subscription preferences": "Preferencias de la suscripción", - "Show annotations by default for subscribed channels: ": "¿Mostrar anotaciones por defecto para los canales suscritos? ", + "preferences_annotations_subscribed_label": "¿Mostrar anotaciones por defecto para los canales suscritos? ", "Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ", - "Number of videos shown in feed: ": "Número de vídeos mostrados en la fuente: ", - "Sort videos by: ": "Ordenar los vídeos por: ", + "preferences_max_results_label": "Número de vídeos mostrados en la fuente: ", + "preferences_sort_label": "Ordenar los vídeos por: ", "published": "fecha de publicación", "published - reverse": "fecha de publicación: orden inverso", "alphabetically": "alfabéticamente", @@ -101,8 +101,8 @@ "channel name - reverse": "nombre del canal: orden inverso", "Only show latest video from channel: ": "Mostrar solo el último vídeo del canal: ", "Only show latest unwatched video from channel: ": "Mostrar solo el último vídeo sin ver del canal: ", - "Only show unwatched: ": "Mostrar solo los no vistos: ", - "Only show notifications (if there are any): ": "Mostrar solo notificaciones (si hay alguna): ", + "preferences_unseen_only_label": "Mostrar solo los no vistos: ", + "preferences_notifications_only_label": "Mostrar solo notificaciones (si hay alguna): ", "Enable web notifications": "Habilitar notificaciones web", "`x` uploaded a video": "`x` subió un video", "`x` is live": "`x` esta en vivo", @@ -115,9 +115,9 @@ "Watch history": "Historial de reproducción", "Delete account": "Borrar cuenta", "Administrator preferences": "Preferencias de administrador", - "Default homepage: ": "Página de inicio por defecto: ", - "Feed menu: ": "Menú de fuentes: ", - "Show nickname on top: ": "Mostrar nombre de usuario arriba: ", + "preferences_default_home_label": "Página de inicio por defecto: ", + "preferences_feed_menu_label": "Menú de fuentes: ", + "preferences_show_nick_label": "Mostrar nombre de usuario arriba: ", "Top enabled: ": "¿Habilitar los destacados? ", "CAPTCHA enabled: ": "¿Habilitar los CAPTCHA? ", "Login enabled: ": "¿Habilitar el inicio de sesión? ", @@ -374,7 +374,7 @@ "Top": "Destacados", "About": "Acerca de", "Rating: ": "Valoración: ", - "Language: ": "Idioma: ", + "preferences_locale_label": "Idioma: ", "View as playlist": "Ver como lista de reproducción", "Default": "Por defecto", "Music": "Música", diff --git a/locales/eu.json b/locales/eu.json index 4fca3d5a..1f0f528a 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -58,20 +58,20 @@ "E-mail": "E-posta", "Preferences": "Hobespenak", "Player preferences": "Erreproduzigailuaren hobespenak", - "Autoplay: ": "Automatikoki erreproduzitu: ", - "Autoplay next video: ": "Erreproduzitu automatikoki hurrengo bideoa: ", - "Preferred video quality: ": "Hobetsitako bideoaren kalitatea: ", - "Player volume: ": "Erreproduzigailuaren bolumena: ", - "Default comments: ": "Lehenetsitako iruzkinak: ", + "preferences_autoplay_label": "Automatikoki erreproduzitu: ", + "preferences_continue_autoplay_label": "Erreproduzitu automatikoki hurrengo bideoa: ", + "preferences_quality_label": "Hobetsitako bideoaren kalitatea: ", + "preferences_volume_label": "Erreproduzigailuaren bolumena: ", + "preferences_comments_label": "Lehenetsitako iruzkinak: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "Lehenetsitako azpitituluak: ", - "Show related videos: ": "Erakutsi erlazionatutako bideoak: ", - "Show annotations by default: ": "Erakutsi oharrak modu lehenetsian: ", + "preferences_captions_label": "Lehenetsitako azpitituluak: ", + "preferences_related_videos_label": "Erakutsi erlazionatutako bideoak: ", + "preferences_annotations_label": "Erakutsi oharrak modu lehenetsian: ", "Visual preferences": "Hobespen bisualak", - "Player style: ": "Erreproduzigailu mota: ", + "preferences_player_style_label": "Erreproduzigailu mota: ", "Dark mode: ": "Gai iluna: ", - "Theme: ": "Gaia: ", + "preferences_dark_mode_label": "Gaia: ", "dark": "iluna", "light": "argia", "Subscription preferences": "Harpidetzen hobespenak" diff --git a/locales/fa.json b/locales/fa.json index c7842206..304b0764 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -61,38 +61,38 @@ "Google verification code": "کد تایید گوگل", "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: ": "نظرات پیشفرض: ", + "preferences_video_loop_label": "همیشه تکرار شنوده: ", + "preferences_autoplay_label": "نمایش خودکار: ", + "preferences_continue_label": "پخش بعدی به طور پیشفرض: ", + "preferences_continue_autoplay_label": "پخش خودکار ویدیو بعدی: ", + "preferences_listen_label": "گوش کردن به طور پیشفرض: ", + "preferences_local_label": "پروکسی ویدیو ها: ", + "preferences_speed_label": "سرعت پیشفرض: ", + "preferences_quality_label": "کیفیت ویدیوی ترجیحی: ", + "preferences_volume_label": "صدای پخش کننده: ", + "preferences_comments_label": "نظرات پیشفرض: ", "youtube": "یوتیوب", "reddit": "ردیت", - "Default captions: ": "زیرنویس های پیشفرض: ", + "preferences_captions_label": "زیرنویس های پیشفرض: ", "Fallback captions: ": "عقب گرد زیرنویس ها: ", - "Show related videos: ": "نمایش ویدیو های مرتبط: ", - "Show annotations by default: ": "نمایش حاشیه نویسی ها به طور پیشفرض: ", - "Automatically extend video description: ": "گسترش خودکار توضیحات ویدئو: ", - "Interactive 360 degree videos: ": "ویدئوها ۳۶۰ درجه تعاملی: ", + "preferences_related_videos_label": "نمایش ویدیو های مرتبط: ", + "preferences_annotations_label": "نمایش حاشیه نویسی ها به طور پیشفرض: ", + "preferences_extend_desc_label": "گسترش خودکار توضیحات ویدئو: ", + "preferences_vr_mode_label": "ویدئوها ۳۶۰ درجه تعاملی: ", "Visual preferences": "ترجیحات بصری", - "Player style: ": "حالت پخش کننده: ", + "preferences_player_style_label": "حالت پخش کننده: ", "Dark mode: ": "حالت تاریک: ", - "Theme: ": "تم: ", + "preferences_dark_mode_label": "تم: ", "dark": "تاریک", "light": "روشن", - "Thin mode: ": "حالت نازک: ", + "preferences_thin_mode_label": "حالت نازک: ", "Miscellaneous preferences": "ترجیحات متفرقه", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "هدایت خودکار نمونه (به طور پیش‌فرض به redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "هدایت خودکار نمونه (به طور پیش‌فرض به redirect.invidious.io): ", "Subscription preferences": "ترجیحات اشتراک", - "Show annotations by default for subscribed channels: ": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ", + "preferences_annotations_subscribed_label": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ", "Redirect homepage to feed: ": "تغییر مسیر صفحه خانه به خوراک: ", - "Number of videos shown in feed: ": "تعداد ویدیو های نمایش داده شده در خوراک: ", - "Sort videos by: ": "مرتب سازی ویدیو ها بر اساس: ", + "preferences_max_results_label": "تعداد ویدیو های نمایش داده شده در خوراک: ", + "preferences_sort_label": "مرتب سازی ویدیو ها بر اساس: ", "published": "منتشر شده", "published - reverse": "منتشر شده - معکوس", "alphabetically": "بر اساس حروف الفبا", @@ -101,8 +101,8 @@ "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): ": "تنها نمایش اعلان ها (اگر وجود داشته باشد) ", + "preferences_unseen_only_label": "تنها نمایش ویدیو های تماشا نشده: ", + "preferences_notifications_only_label": "تنها نمایش اعلان ها (اگر وجود داشته باشد) ", "Enable web notifications": "فعال کردن اعلان های وب", "`x` uploaded a video": "`x` یک ویدیو بارگذاری کرد", "`x` is live": "`x` زنده است", @@ -115,9 +115,9 @@ "Watch history": "تاریخچه تماشا", "Delete account": "حذف حساب کاربری", "Administrator preferences": "ترجیحات مدیریت", - "Default homepage: ": "صفحه خانه پیشفرض ", - "Feed menu: ": "منو خوراک: ", - "Show nickname on top: ": "نمایش نام مستعار در بالا: ", + "preferences_default_home_label": "صفحه خانه پیشفرض ", + "preferences_feed_menu_label": "منو خوراک: ", + "preferences_show_nick_label": "نمایش نام مستعار در بالا: ", "Top enabled: ": "بالا فعال شده: ", "CAPTCHA enabled: ": "CAPTCHA فعال شده: ", "Login enabled: ": "ورود فعال شده: ", @@ -374,7 +374,7 @@ "Top": "بالا", "About": "درباره", "Rating: ": "رتبه دهی: ", - "Language: ": "زبان: ", + "preferences_locale_label": "زبان: ", "View as playlist": "نمایش به عنوان سیاههٔ پخش", "Default": "پیشفرض", "Music": "موسیقی", diff --git a/locales/fi.json b/locales/fi.json index f76baacf..3669fa6b 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -61,38 +61,38 @@ "Google verification code": "Google-vahvistuskoodi", "Preferences": "Asetukset", "Player preferences": "Soittimen asetukset", - "Always loop: ": "Aina silmukka: ", - "Autoplay: ": "Automaattinen toisto: ", - "Play next by default: ": "Toista seuraava oletuksena: ", - "Autoplay next video: ": "Toista seuraava video automaattisesti: ", - "Listen by default: ": "Kuuntele oletuksena: ", - "Proxy videos: ": "Proxy videot: ", - "Default speed: ": "Oletusnopeus: ", - "Preferred video quality: ": "Ensisijainen videon laatu: ", - "Player volume: ": "Soittimen äänenvoimakkuus: ", - "Default comments: ": "Oletuskommentit: ", + "preferences_video_loop_label": "Aina silmukka: ", + "preferences_autoplay_label": "Automaattinen toisto: ", + "preferences_continue_label": "Toista seuraava oletuksena: ", + "preferences_continue_autoplay_label": "Toista seuraava video automaattisesti: ", + "preferences_listen_label": "Kuuntele oletuksena: ", + "preferences_local_label": "Proxy videot: ", + "preferences_speed_label": "Oletusnopeus: ", + "preferences_quality_label": "Ensisijainen videon laatu: ", + "preferences_volume_label": "Soittimen äänenvoimakkuus: ", + "preferences_comments_label": "Oletuskommentit: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Tekstitykset: ", + "preferences_captions_label": "Tekstitykset: ", "Fallback captions: ": "Toissijaiset tekstitykset: ", - "Show related videos: ": "Näytä aiheeseen liittyviä videoita: ", - "Show annotations by default: ": "Näytä huomautukset oletuksena: ", - "Automatically extend video description: ": "Laajenna automaattisesti videon kuvausta: ", - "Interactive 360 degree videos: ": "Interaktiiviset 360-asteiset videot: ", + "preferences_related_videos_label": "Näytä aiheeseen liittyviä videoita: ", + "preferences_annotations_label": "Näytä huomautukset oletuksena: ", + "preferences_extend_desc_label": "Laajenna automaattisesti videon kuvausta: ", + "preferences_vr_mode_label": "Interaktiiviset 360-asteiset videot: ", "Visual preferences": "Visuaaliset asetukset", - "Player style: ": "Soittimen tyyli: ", + "preferences_player_style_label": "Soittimen tyyli: ", "Dark mode: ": "Tumma tila: ", - "Theme: ": "Teema: ", + "preferences_dark_mode_label": "Teema: ", "dark": "tumma", "light": "vaalea", - "Thin mode: ": "Kapea tila ", + "preferences_thin_mode_label": "Kapea tila ", "Miscellaneous preferences": "Sekalaiset asetukset", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automaattinen palveluntarjoajan uudelleenohjaus (perääntyminen sivulle redirect.invidious.io) ", + "preferences_automatic_instance_redirect_label": "Automaattinen palveluntarjoajan uudelleenohjaus (perääntyminen sivulle redirect.invidious.io) ", "Subscription preferences": "Tilausten asetukset", - "Show annotations by default for subscribed channels: ": "Näytä oletuksena tilattujen kanavien huomautukset: ", + "preferences_annotations_subscribed_label": "Näytä oletuksena tilattujen kanavien huomautukset: ", "Redirect homepage to feed: ": "Uudelleenohjaa kotisivu syötteeseen: ", - "Number of videos shown in feed: ": "Syötteessä näytettävien videoiden määrä: ", - "Sort videos by: ": "Videoiden lajitteluperuste: ", + "preferences_max_results_label": "Syötteessä näytettävien videoiden määrä: ", + "preferences_sort_label": "Videoiden lajitteluperuste: ", "published": "julkaistu", "published - reverse": "julkaistu - käänteinen", "alphabetically": "aakkosjärjestys", @@ -101,8 +101,8 @@ "channel name - reverse": "kanavan nimi - käänteinen", "Only show latest video from channel: ": "Näytä vain uusin video kanavalta: ", "Only show latest unwatched video from channel: ": "Näytä vain uusin katsomaton video kanavalta: ", - "Only show unwatched: ": "Näytä vain katsomattomat: ", - "Only show notifications (if there are any): ": "Näytä vain ilmoitukset (jos niitä on): ", + "preferences_unseen_only_label": "Näytä vain katsomattomat: ", + "preferences_notifications_only_label": "Näytä vain ilmoitukset (jos niitä on): ", "Enable web notifications": "Näytä verkkoilmoitukset", "`x` uploaded a video": "`x` latasi videon", "`x` is live": "`x` lähettää suorana", @@ -115,9 +115,9 @@ "Watch history": "Katseluhistoria", "Delete account": "Poista tili", "Administrator preferences": "Järjestelmänvalvojan asetukset", - "Default homepage: ": "Oletuskotisivu: ", - "Feed menu: ": "Syötevalikko: ", - "Show nickname on top: ": "Näytä nimimerkki ylimpänä: ", + "preferences_default_home_label": "Oletuskotisivu: ", + "preferences_feed_menu_label": "Syötevalikko: ", + "preferences_show_nick_label": "Näytä nimimerkki ylimpänä: ", "Top enabled: ": "Yläosa käytössä: ", "CAPTCHA enabled: ": "CAPTCHA käytössä: ", "Login enabled: ": "Kirjautuminen käytössä: ", @@ -373,7 +373,7 @@ "Top": "Ylin", "About": "Tietoa", "Rating: ": "Arvosana: ", - "Language: ": "Kieli: ", + "preferences_locale_label": "Kieli: ", "View as playlist": "Näytä soittolistana", "Default": "Oletus", "Music": "Musiikki", diff --git a/locales/fr.json b/locales/fr.json index a7fe004d..c9cea197 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -61,38 +61,38 @@ "Google verification code": "Code de vérification Google", "Preferences": "Préférences", "Player preferences": "Préférences du lecteur", - "Always loop: ": "Lire en boucle : ", - "Autoplay: ": "Lancer la lecture automatiquement : ", - "Play next by default: ": "Lire les vidéos suivantes par défaut : ", - "Autoplay next video: ": "Lire automatiquement la vidéo suivante : ", - "Listen by default: ": "Audio uniquement : ", - "Proxy videos: ": "Charger les vidéos à travers un proxy : ", - "Default speed: ": "Vitesse par défaut : ", - "Preferred video quality: ": "Qualité vidéo souhaitée : ", - "Player volume: ": "Volume du lecteur : ", - "Default comments: ": "Source des commentaires : ", + "preferences_video_loop_label": "Lire en boucle : ", + "preferences_autoplay_label": "Lancer la lecture automatiquement : ", + "preferences_continue_label": "Lire les vidéos suivantes par défaut : ", + "preferences_continue_autoplay_label": "Lire automatiquement la vidéo suivante : ", + "preferences_listen_label": "Audio uniquement : ", + "preferences_local_label": "Charger les vidéos à travers un proxy : ", + "preferences_speed_label": "Vitesse par défaut : ", + "preferences_quality_label": "Qualité vidéo souhaitée : ", + "preferences_volume_label": "Volume du lecteur : ", + "preferences_comments_label": "Source des commentaires : ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Sous-titres par défaut : ", + "preferences_captions_label": "Sous-titres par défaut : ", "Fallback captions: ": "Sous-titres alternatifs : ", - "Show related videos: ": "Voir les vidéos liées : ", - "Show annotations by default: ": "Afficher les annotations par défaut : ", - "Automatically extend video description: ": "Etendre automatiquement la description : ", - "Interactive 360 degree videos: ": "Vidéos interactives à 360° : ", + "preferences_related_videos_label": "Voir les vidéos liées : ", + "preferences_annotations_label": "Afficher les annotations par défaut : ", + "preferences_extend_desc_label": "Etendre automatiquement la description : ", + "preferences_vr_mode_label": "Vidéos interactives à 360° : ", "Visual preferences": "Préférences du site", - "Player style: ": "Style du lecteur : ", + "preferences_player_style_label": "Style du lecteur : ", "Dark mode: ": "Mode sombre : ", - "Theme: ": "Thème : ", + "preferences_dark_mode_label": "Thème : ", "dark": "sombre", "light": "clair", - "Thin mode: ": "Mode léger : ", + "preferences_thin_mode_label": "Mode léger : ", "Miscellaneous preferences": "Paramètres divers", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirection vers une autre instance automatique (via redirect.invidious.io) : ", + "preferences_automatic_instance_redirect_label": "Redirection vers une autre instance automatique (via redirect.invidious.io) : ", "Subscription preferences": "Préférences des abonnements", - "Show annotations by default for subscribed channels: ": "Afficher les annotations par défaut sur les chaînes auxquelles vous êtes abonnés : ", + "preferences_annotations_subscribed_label": "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 : ", + "preferences_max_results_label": "Nombre de vidéos affichées dans la page d'abonnements : ", + "preferences_sort_label": "Trier les vidéos par : ", "published": "date de publication", "published - reverse": "date de publication - inversé", "alphabetically": "ordre alphabétique", @@ -101,8 +101,8 @@ "channel name - reverse": "nom de la chaîne - inversé", "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 été regardée : ", - "Only show unwatched: ": "Afficher uniquement les vidéos qui n'ont pas été regardées : ", - "Only show notifications (if there are any): ": "Afficher uniquement les notifications (s'il y en a) : ", + "preferences_unseen_only_label": "Afficher uniquement les vidéos qui n'ont pas été regardées : ", + "preferences_notifications_only_label": "Afficher uniquement les notifications (s'il y en a) : ", "Enable web notifications": "Activer les notifications web", "`x` uploaded a video": "`x` a partagé une vidéo", "`x` is live": "`x` est en direct", @@ -115,9 +115,9 @@ "Watch history": "Historique de visionnage", "Delete account": "Supprimer votre compte", "Administrator preferences": "Préferences d'Administration", - "Default homepage: ": "Page d'accueil par défaut : ", - "Feed menu: ": "Préferences des abonnements : ", - "Show nickname on top: ": "Afficher le nom d'utilisateur en haut à droite : ", + "preferences_default_home_label": "Page d'accueil par défaut : ", + "preferences_feed_menu_label": "Préferences des abonnements : ", + "preferences_show_nick_label": "Afficher le nom d'utilisateur en haut à droite : ", "Top enabled: ": "Top activé : ", "CAPTCHA enabled: ": "CAPTCHA activé : ", "Login enabled: ": "Autoriser l'ouverture de sessions utilisateur : ", @@ -374,7 +374,7 @@ "Top": "Top", "About": "À propos", "Rating: ": "Évaluation : ", - "Language: ": "Langue : ", + "preferences_locale_label": "Langue : ", "View as playlist": "Voir en tant que liste de lecture", "Default": "Défaut", "Music": "Musique", diff --git a/locales/he.json b/locales/he.json index 3ed4a5e5..7383c4ba 100644 --- a/locales/he.json +++ b/locales/he.json @@ -61,30 +61,30 @@ "Google verification code": "קוד האימות של Google", "Preferences": "העדפות", "Player preferences": "העדפות הנגן", - "Autoplay: ": "ניגון אוטומטי: ", - "Play next by default: ": "ניגון הסרטון הבא כברירת מחדל: ", - "Autoplay next video: ": "ניגון הסרטון הבא באופן אוטומטי: ", - "Listen by default: ": "שמע כברירת מחדל: ", - "Default speed: ": "מהירות ברירת המחדל: ", - "Preferred video quality: ": "איכות הווידאו המועדפת: ", - "Player volume: ": "עצמת השמע של הנגן: ", - "Default comments: ": "תגובות ברירת מחדל ", + "preferences_autoplay_label": "ניגון אוטומטי: ", + "preferences_continue_label": "ניגון הסרטון הבא כברירת מחדל: ", + "preferences_continue_autoplay_label": "ניגון הסרטון הבא באופן אוטומטי: ", + "preferences_listen_label": "שמע כברירת מחדל: ", + "preferences_speed_label": "מהירות ברירת המחדל: ", + "preferences_quality_label": "איכות הווידאו המועדפת: ", + "preferences_volume_label": "עצמת השמע של הנגן: ", + "preferences_comments_label": "תגובות ברירת מחדל ", "youtube": "יוטיוב", "reddit": "reddit", - "Default captions: ": "כתוביות ברירת מחדל ", + "preferences_captions_label": "כתוביות ברירת מחדל ", "Fallback captions: ": "כתוביות גיבוי ", - "Show related videos: ": "הצגת סרטונים קשורים: ", - "Show annotations by default: ": "הצגת הערות כברירת מחדל: ", + "preferences_related_videos_label": "הצגת סרטונים קשורים: ", + "preferences_annotations_label": "הצגת הערות כברירת מחדל: ", "Visual preferences": "העדפות חזותיות", - "Player style: ": "סגנון הנגן: ", + "preferences_player_style_label": "סגנון הנגן: ", "Dark mode: ": "מצב כהה: ", - "Theme: ": "ערכת נושא: ", + "preferences_dark_mode_label": "ערכת נושא: ", "dark": "כהה", "light": "בהיר", "Subscription preferences": "העדפות מינויים", - "Show annotations by default for subscribed channels: ": "Show annotations by default for subscribed channels? ", - "Number of videos shown in feed: ": "מספר הסרטונים שמוצגים בהזנה: ", - "Sort videos by: ": "מיון הסרטונים לפי: ", + "preferences_annotations_subscribed_label": "Show annotations by default for subscribed channels? ", + "preferences_max_results_label": "מספר הסרטונים שמוצגים בהזנה: ", + "preferences_sort_label": "מיון הסרטונים לפי: ", "published": "פורסם", "alphabetically": "בסדר אלפביתי", "alphabetically - reverse": "בסדר אלפביתי - הפוך", @@ -92,8 +92,8 @@ "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): ": "הצגת התראות בלבד (אם ישנן): ", + "preferences_unseen_only_label": "הצגת סרטונים שלא נצפו בלבד: ", + "preferences_notifications_only_label": "הצגת התראות בלבד (אם ישנן): ", "`x` uploaded a video": "סרטון הועלה על ידי `x`", "`x` is live": "`x` בשידור חי", "Data preferences": "העדפות נתונים", @@ -105,8 +105,8 @@ "Watch history": "היסטוריית צפייה", "Delete account": "מחיקת החשבון", "Administrator preferences": "הגדרות ניהול מערכת", - "Default homepage: ": "Default homepage: ", - "Feed menu: ": "תפריט ההזנה: ", + "preferences_default_home_label": "Default homepage: ", + "preferences_feed_menu_label": "תפריט ההזנה: ", "Save preferences": "שמירת ההעדפות", "Subscription manager": "מנהל המינויים", "Token manager": "Token manager", @@ -313,7 +313,7 @@ "Top": "Top", "About": "על אודות", "Rating: ": "דירוג: ", - "Language: ": "שפה: ", + "preferences_locale_label": "שפה: ", "View as playlist": "הצגה כפלייליסט", "Default": "ברירת מחדל", "Music": "מוזיקה", diff --git a/locales/hr.json b/locales/hr.json index c426a211..09041c6d 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -61,38 +61,38 @@ "Google verification code": "Googleov potvrdni kod", "Preferences": "Postavke", "Player preferences": "Postavke playera", - "Always loop: ": "Uvijek ponavljaj: ", - "Autoplay: ": "Automatski reproduciraj: ", - "Play next by default: ": "Standardno reproduciraj sljedeći: ", - "Autoplay next video: ": "Automatski reproduciraj sljedeći video: ", - "Listen by default: ": "Standardno slušaj: ", - "Proxy videos: ": "Koristi posrednika videa: ", - "Default speed: ": "Standardna brzina: ", - "Preferred video quality: ": "Primarna kvaliteta videa: ", - "Player volume: ": "Glasnoća playera: ", - "Default comments: ": "Standardni komentari: ", + "preferences_video_loop_label": "Uvijek ponavljaj: ", + "preferences_autoplay_label": "Automatski reproduciraj: ", + "preferences_continue_label": "Standardno reproduciraj sljedeći: ", + "preferences_continue_autoplay_label": "Automatski reproduciraj sljedeći video: ", + "preferences_listen_label": "Standardno slušaj: ", + "preferences_local_label": "Koristi posrednika videa: ", + "preferences_speed_label": "Standardna brzina: ", + "preferences_quality_label": "Primarna kvaliteta videa: ", + "preferences_volume_label": "Glasnoća playera: ", + "preferences_comments_label": "Standardni komentari: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Standardni titlovi: ", + "preferences_captions_label": "Standardni titlovi: ", "Fallback captions: ": "Alternativni titlovi: ", - "Show related videos: ": "Prikaži povezana videa: ", - "Show annotations by default: ": "Standardno prikaži napomene: ", - "Automatically extend video description: ": "Automatski proširi opis videa: ", - "Interactive 360 degree videos: ": "Interaktivna videa od 360 stupnjeva: ", + "preferences_related_videos_label": "Prikaži povezana videa: ", + "preferences_annotations_label": "Standardno prikaži napomene: ", + "preferences_extend_desc_label": "Automatski proširi opis videa: ", + "preferences_vr_mode_label": "Interaktivna videa od 360 stupnjeva: ", "Visual preferences": "Postavke prikaza", - "Player style: ": "Stil playera: ", + "preferences_player_style_label": "Stil playera: ", "Dark mode: ": "Tamni modus: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "tamno", "light": "svijetlo", - "Thin mode: ": "Pojednostavljen prikaz: ", + "preferences_thin_mode_label": "Pojednostavljen prikaz: ", "Miscellaneous preferences": "Razne postavke", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatsko preusmjeravanje instance (u krajnjem slučaju koristi redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Automatsko preusmjeravanje instance (u krajnjem slučaju koristi redirect.invidious.io): ", "Subscription preferences": "Postavke pretplata", - "Show annotations by default for subscribed channels: ": "Standardno prikaži napomene za pretplaćene kanale: ", + "preferences_annotations_subscribed_label": "Standardno prikaži napomene za pretplaćene kanale: ", "Redirect homepage to feed: ": "Preusmjeri početnu stranicu na feed: ", - "Number of videos shown in feed: ": "Broj prikazanih videa u feedu: ", - "Sort videos by: ": "Razvrstaj videa prema: ", + "preferences_max_results_label": "Broj prikazanih videa u feedu: ", + "preferences_sort_label": "Razvrstaj videa prema: ", "published": "objavljeno", "published - reverse": "objavljeno – obrnuto", "alphabetically": "abecednim redom", @@ -101,8 +101,8 @@ "channel name - reverse": "ime kanala – obrnuto", "Only show latest video from channel: ": "Prikaži samo najnovija videa kanala: ", "Only show latest unwatched video from channel: ": "Prikaži samo najnovija nepogledana videa kanala: ", - "Only show unwatched: ": "Prikaži samo nepogledane: ", - "Only show notifications (if there are any): ": "Prikaži samo obavijesti (ako ih ima): ", + "preferences_unseen_only_label": "Prikaži samo nepogledane: ", + "preferences_notifications_only_label": "Prikaži samo obavijesti (ako ih ima): ", "Enable web notifications": "Aktiviraj web-obavijesti", "`x` uploaded a video": "`x` je poslao/la video", "`x` is live": "`x` je uživo", @@ -115,9 +115,9 @@ "Watch history": "Povijest gledanja", "Delete account": "Izbriši račun", "Administrator preferences": "Postavke administratora", - "Default homepage: ": "Standardna početna stranica: ", - "Feed menu: ": "Izbornik za feedove: ", - "Show nickname on top: ": "Prikaži nadimak na vrhu: ", + "preferences_default_home_label": "Standardna početna stranica: ", + "preferences_feed_menu_label": "Izbornik za feedove: ", + "preferences_show_nick_label": "Prikaži nadimak na vrhu: ", "Top enabled: ": "Najbolji aktivirani: ", "CAPTCHA enabled: ": "Aktivirani CAPTCHA: ", "Login enabled: ": "Prijava aktivirana: ", @@ -374,7 +374,7 @@ "Top": "Najbolji", "About": "Informacije", "Rating: ": "Ocjena: ", - "Language: ": "Jezik: ", + "preferences_locale_label": "Jezik: ", "View as playlist": "Prikaži kao playlistu", "Default": "Standardno", "Music": "Glazba", diff --git a/locales/hu-HU.json b/locales/hu-HU.json index 6c7d5049..4f68a1d5 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -58,36 +58,36 @@ "Google verification code": "Google verifikációs kód", "Preferences": "Beállítások", "Player preferences": "Lejátszó beállítások", - "Always loop: ": "Mindig loop-ol: ", - "Autoplay: ": "Automatikus lejátszás: ", - "Play next by default: ": "Következő lejátszása alapértelmezésben: ", - "Autoplay next video: ": "Következő automatikus lejátszása: ", - "Listen by default: ": "Hallgatás alapértelmezésben: ", - "Proxy videos: ": "Videók proxyzása: ", - "Default speed: ": "Alapértelmezett sebesség: ", - "Preferred video quality: ": "Kívánt video minőség: ", - "Player volume: ": "Hangerő: ", - "Default comments: ": "Alapértelmezett kommentek: ", + "preferences_video_loop_label": "Mindig loop-ol: ", + "preferences_autoplay_label": "Automatikus lejátszás: ", + "preferences_continue_label": "Következő lejátszása alapértelmezésben: ", + "preferences_continue_autoplay_label": "Következő automatikus lejátszása: ", + "preferences_listen_label": "Hallgatás alapértelmezésben: ", + "preferences_local_label": "Videók proxyzása: ", + "preferences_speed_label": "Alapértelmezett sebesség: ", + "preferences_quality_label": "Kívánt video minőség: ", + "preferences_volume_label": "Hangerő: ", + "preferences_comments_label": "Alapértelmezett kommentek: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "Alapértelmezett feliratok: ", + "preferences_captions_label": "Alapértelmezett feliratok: ", "Fallback captions: ": "Másodlagos feliratok: ", - "Show related videos: ": "Hasonló videók mutatása: ", - "Show annotations by default: ": "Szövegmagyarázatok mutatása alapértelmezésben: ", - "Automatically extend video description: ": "Automatikusan növelje meg a videó leírását", - "Interactive 360 degree videos: ": "Interaktív 360° videók", + "preferences_related_videos_label": "Hasonló videók mutatása: ", + "preferences_annotations_label": "Szövegmagyarázatok mutatása alapértelmezésben: ", + "preferences_extend_desc_label": "Automatikusan növelje meg a videó leírását", + "preferences_vr_mode_label": "Interaktív 360° videók", "Visual preferences": "Kinézeti beállítások", - "Player style: ": "Lejátszó stílusa: ", + "preferences_player_style_label": "Lejátszó stílusa: ", "Dark mode: ": "Sötét mód: ", - "Theme: ": "Téma: ", + "preferences_dark_mode_label": "Téma: ", "dark": "sötét", "light": "világos", - "Thin mode: ": "Vékony mód: ", + "preferences_thin_mode_label": "Vékony mód: ", "Subscription preferences": "Feliratkozási beállítások", - "Show annotations by default for subscribed channels: ": "Szövegmagyarázatok mutatása alapértelmezésben feliratkozott csatornák esetében: ", + "preferences_annotations_subscribed_label": "Szövegmagyarázatok mutatása alapértelmezésben feliratkozott csatornák esetében: ", "Redirect homepage to feed: ": "Kezdő oldal átirányitása a feed-re: ", - "Number of videos shown in feed: ": "Feed-ben mutatott videók száma: ", - "Sort videos by: ": "Videók sorrendje: ", + "preferences_max_results_label": "Feed-ben mutatott videók száma: ", + "preferences_sort_label": "Videók sorrendje: ", "published": "közzétéve", "published - reverse": "közzétéve - fordítva", "alphabetically": "ABC sorrend", @@ -96,8 +96,8 @@ "channel name - reverse": "csatorna neve - fordítva", "Only show latest video from channel: ": "Csak a legutolsó videó mutatása a csatornából: ", "Only show latest unwatched video from channel: ": "Csak a legutolsó nem megtekintett videó mutatása a csatornából: ", - "Only show unwatched: ": "Csak a nem megtekintettek mutatása: ", - "Only show notifications (if there are any): ": "Csak értesítések mutatása (ha van): ", + "preferences_unseen_only_label": "Csak a nem megtekintettek mutatása: ", + "preferences_notifications_only_label": "Csak értesítések mutatása (ha van): ", "Enable web notifications": "Web értesítések bekapcsolása", "`x` uploaded a video": "`x` feltöltött egy videót", "`x` is live": "`x` élő", @@ -110,8 +110,8 @@ "Watch history": "Megtekintési napló", "Delete account": "Fiók törlése", "Administrator preferences": "Adminisztrátor beállítások", - "Default homepage: ": "Alapértelmezett oldal: ", - "Feed menu: ": "Feed menü: ", + "preferences_default_home_label": "Alapértelmezett oldal: ", + "preferences_feed_menu_label": "Feed menü: ", "Top enabled: ": "Top lista engedélyezve: ", "CAPTCHA enabled: ": "CAPTCHA engedélyezve: ", "Login enabled: ": "Bejelentkezés engedélyezve: ", @@ -348,7 +348,7 @@ "Top": "Top", "About": "Leírás", "Rating: ": "Besorolás: ", - "Language: ": "Nyelv: ", + "preferences_locale_label": "Nyelv: ", "View as playlist": "Megtekintés lejátszási listaként", "Default": "Alapértelmezett", "Music": "Zene", diff --git a/locales/id.json b/locales/id.json index 336b5981..015d9b10 100644 --- a/locales/id.json +++ b/locales/id.json @@ -61,38 +61,38 @@ "Google verification code": "Kode verifikasi Google", "Preferences": "Preferensi", "Player preferences": "Preferensi pemutar", - "Always loop: ": "Selalu ulangi: ", - "Autoplay: ": "Putar-Otomatis: ", - "Play next by default: ": "Putar selanjutnya secara default: ", - "Autoplay next video: ": "Otomatis-Putar video berikutnya: ", - "Listen by default: ": "Dengarkan secara default: ", - "Proxy videos: ": "Video Proksi: ", - "Default speed: ": "Kecepatan default: ", - "Preferred video quality: ": "Kualitas video yang disukai: ", - "Player volume: ": "Volume pemutar: ", - "Default comments: ": "Komentar default: ", + "preferences_video_loop_label": "Selalu ulangi: ", + "preferences_autoplay_label": "Putar-Otomatis: ", + "preferences_continue_label": "Putar selanjutnya secara default: ", + "preferences_continue_autoplay_label": "Otomatis-Putar video berikutnya: ", + "preferences_listen_label": "Dengarkan secara default: ", + "preferences_local_label": "Video Proksi: ", + "preferences_speed_label": "Kecepatan default: ", + "preferences_quality_label": "Kualitas video yang disukai: ", + "preferences_volume_label": "Volume pemutar: ", + "preferences_comments_label": "Komentar default: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Subtitel default: ", + "preferences_captions_label": "Subtitel default: ", "Fallback captions: ": "Subtitel fallback: ", - "Show related videos: ": "Tampilkan video terkait: ", - "Show annotations by default: ": "Tampilkan anotasi secara default: ", - "Automatically extend video description: ": "Perluas deskripsi video secara otomatis: ", - "Interactive 360 degree videos: ": "Video interaktif 360°: ", + "preferences_related_videos_label": "Tampilkan video terkait: ", + "preferences_annotations_label": "Tampilkan anotasi secara default: ", + "preferences_extend_desc_label": "Perluas deskripsi video secara otomatis: ", + "preferences_vr_mode_label": "Video interaktif 360°: ", "Visual preferences": "Preferensi visual", - "Player style: ": "Gaya pemutar: ", + "preferences_player_style_label": "Gaya pemutar: ", "Dark mode: ": "Mode gelap: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "gelap", "light": "terang", - "Thin mode: ": "Mode tipis: ", + "preferences_thin_mode_label": "Mode tipis: ", "Miscellaneous preferences": "Preferensi lainnya", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Pengalihan instans otomatis (fallback ke redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Pengalihan instans otomatis (fallback ke redirect.invidious.io): ", "Subscription preferences": "Preferensi langganan", - "Show annotations by default for subscribed channels: ": "Tampilkan anotasi secara default untuk kanal langganan: ", + "preferences_annotations_subscribed_label": "Tampilkan anotasi secara default untuk kanal langganan: ", "Redirect homepage to feed: ": "Arahkan kembali laman beranda ke umpan: ", - "Number of videos shown in feed: ": "Jumlah video ditampilkan di umpan: ", - "Sort videos by: ": "Urutkan video berdasarkan: ", + "preferences_max_results_label": "Jumlah video ditampilkan di umpan: ", + "preferences_sort_label": "Urutkan video berdasarkan: ", "published": "dipublikasi", "published - reverse": "dipublikasi - sebaliknya", "alphabetically": "menurut abjad", @@ -101,8 +101,8 @@ "channel name - reverse": "nama kanal - sebaliknya", "Only show latest video from channel: ": "Hanya tampilkan video terbaru dari kanal: ", "Only show latest unwatched video from channel: ": "Hanya tampilkan video belum ditonton terbaru dari kanal: ", - "Only show unwatched: ": "Hanya tampilkan belum ditonton: ", - "Only show notifications (if there are any): ": "Hanya tampilkan pemberitahuan (jika ada): ", + "preferences_unseen_only_label": "Hanya tampilkan belum ditonton: ", + "preferences_notifications_only_label": "Hanya tampilkan pemberitahuan (jika ada): ", "Enable web notifications": "Aktifkan pemberitahuan web", "`x` uploaded a video": "`x` mengunggah video", "`x` is live": "`x` sedang siaran langsung", @@ -115,9 +115,9 @@ "Watch history": "Riwayat tontonan", "Delete account": "Hapus akun", "Administrator preferences": "Preferensi administrator", - "Default homepage: ": "Laman beranda default: ", - "Feed menu: ": "Menu umpan: ", - "Show nickname on top: ": "Tampilkan nama panggilan di atas: ", + "preferences_default_home_label": "Laman beranda default: ", + "preferences_feed_menu_label": "Menu umpan: ", + "preferences_show_nick_label": "Tampilkan nama panggilan di atas: ", "Top enabled: ": "Teratas diaktifkan: ", "CAPTCHA enabled: ": "CAPTCHA diaktifkan: ", "Login enabled: ": "Masuk diaktifkan: ", @@ -374,7 +374,7 @@ "Top": "Teratas", "About": "Ihwal", "Rating: ": "Peringkat: ", - "Language: ": "Bahasa: ", + "preferences_locale_label": "Bahasa: ", "View as playlist": "Tampilkan sebagai daftar putar", "Default": "Asali", "Music": "Musik", diff --git a/locales/is.json b/locales/is.json index 42ccb43b..fecc4116 100644 --- a/locales/is.json +++ b/locales/is.json @@ -61,34 +61,34 @@ "Google verification code": "Google staðfestingarkóði", "Preferences": "Kjörstillingar", "Player preferences": "Kjörstillingar spilara", - "Always loop: ": "Alltaf lykkja: ", - "Autoplay: ": "Spila sjálfkrafa: ", - "Play next by default: ": "Spila næst sjálfgefið: ", - "Autoplay next video: ": "Spila næst sjálfkrafa: ", - "Listen by default: ": "Hlusta sjálfgefið: ", - "Proxy videos: ": "Proxy myndbönd? ", - "Default speed: ": "Sjálfgefinn hraði: ", - "Preferred video quality: ": "Æskilegt myndbands gæði: ", - "Player volume: ": "Spilara hljóðstyrkur: ", - "Default comments: ": "Sjálfgefin ummæli: ", + "preferences_video_loop_label": "Alltaf lykkja: ", + "preferences_autoplay_label": "Spila sjálfkrafa: ", + "preferences_continue_label": "Spila næst sjálfgefið: ", + "preferences_continue_autoplay_label": "Spila næst sjálfkrafa: ", + "preferences_listen_label": "Hlusta sjálfgefið: ", + "preferences_local_label": "Proxy myndbönd? ", + "preferences_speed_label": "Sjálfgefinn hraði: ", + "preferences_quality_label": "Æskilegt myndbands gæði: ", + "preferences_volume_label": "Spilara hljóðstyrkur: ", + "preferences_comments_label": "Sjálfgefin ummæli: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "Sjálfgefin texti: ", + "preferences_captions_label": "Sjálfgefin texti: ", "Fallback captions: ": "Varatextar: ", - "Show related videos: ": "Sýna tengd myndbönd? ", - "Show annotations by default: ": "Á að sýna glósur sjálfgefið? ", + "preferences_related_videos_label": "Sýna tengd myndbönd? ", + "preferences_annotations_label": "Á að sýna glósur sjálfgefið? ", "Visual preferences": "Sjónrænar stillingar", - "Player style: ": "Spilara stíl: ", + "preferences_player_style_label": "Spilara stíl: ", "Dark mode: ": "Myrkur ham: ", - "Theme: ": "Þema: ", + "preferences_dark_mode_label": "Þema: ", "dark": "dimmt", "light": "ljóst", - "Thin mode: ": "Þunnt ham: ", + "preferences_thin_mode_label": "Þunnt ham: ", "Subscription preferences": "Áskriftarstillingar", - "Show annotations by default for subscribed channels: ": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ", + "preferences_annotations_subscribed_label": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ", "Redirect homepage to feed: ": "Endurbeina heimasíðu að straumi: ", - "Number of videos shown in feed: ": "Fjöldi myndbanda sem sýndir eru í straumi: ", - "Sort videos by: ": "Raða myndbönd eftir: ", + "preferences_max_results_label": "Fjöldi myndbanda sem sýndir eru í straumi: ", + "preferences_sort_label": "Raða myndbönd eftir: ", "published": "birt", "published - reverse": "birt - afturábak", "alphabetically": "í stafrófsröð", @@ -97,8 +97,8 @@ "channel name - reverse": "heiti rásar - afturábak", "Only show latest video from channel: ": "Sýna aðeins nýjasta myndband frá rás: ", "Only show latest unwatched video from channel: ": "Sýna aðeins nýjasta óséð myndband frá rás: ", - "Only show unwatched: ": "Sýna aðeins óséð: ", - "Only show notifications (if there are any): ": "Sýna aðeins tilkynningar (ef einhverjar eru): ", + "preferences_unseen_only_label": "Sýna aðeins óséð: ", + "preferences_notifications_only_label": "Sýna aðeins tilkynningar (ef einhverjar eru): ", "Enable web notifications": "Virkja veftilkynningar", "`x` uploaded a video": "`x` hlóð upp myndband", "`x` is live": "`x` er í beinni", @@ -111,8 +111,8 @@ "Watch history": "Áhorfssögu", "Delete account": "Eyða reikningi", "Administrator preferences": "Kjörstillingar stjórnanda", - "Default homepage: ": "Sjálfgefin heimasíða: ", - "Feed menu: ": "Straum valmynd: ", + "preferences_default_home_label": "Sjálfgefin heimasíða: ", + "preferences_feed_menu_label": "Straum valmynd: ", "Top enabled: ": "Toppur virkur? ", "CAPTCHA enabled: ": "CAPTCHA virk? ", "Login enabled: ": "Innskráning virk? ", @@ -363,7 +363,7 @@ "Top": "Topp", "About": "Um", "Rating: ": "Einkunn: ", - "Language: ": "Tungumál: ", + "preferences_locale_label": "Tungumál: ", "View as playlist": "Skoða sem spilunarlista", "Default": "Sjálfgefið", "Music": "Tónlist", diff --git a/locales/it.json b/locales/it.json index b9d6d957..e3ebba89 100644 --- a/locales/it.json +++ b/locales/it.json @@ -61,34 +61,34 @@ "Google verification code": "Codice di verifica Google", "Preferences": "Preferenze", "Player preferences": "Preferenze del riproduttore", - "Always loop: ": "Ripeti sempre: ", - "Autoplay: ": "Riproduzione automatica: ", - "Play next by default: ": "Riproduzione successiva predefinita: ", - "Autoplay next video: ": "Riproduci automaticamente il video successivo: ", - "Listen by default: ": "Modalità solo audio predefinita: ", - "Proxy videos: ": "Proxy per i video: ", - "Default speed: ": "Velocità predefinita: ", - "Preferred video quality: ": "Qualità video preferita: ", - "Player volume: ": "Volume di riproduzione: ", - "Default comments: ": "Origine dei commenti: ", + "preferences_video_loop_label": "Ripeti sempre: ", + "preferences_autoplay_label": "Riproduzione automatica: ", + "preferences_continue_label": "Riproduzione successiva predefinita: ", + "preferences_continue_autoplay_label": "Riproduci automaticamente il video successivo: ", + "preferences_listen_label": "Modalità solo audio predefinita: ", + "preferences_local_label": "Proxy per i video: ", + "preferences_speed_label": "Velocità predefinita: ", + "preferences_quality_label": "Qualità video preferita: ", + "preferences_volume_label": "Volume di riproduzione: ", + "preferences_comments_label": "Origine dei commenti: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Sottotitoli predefiniti: ", + "preferences_captions_label": "Sottotitoli predefiniti: ", "Fallback captions: ": "Sottotitoli alternativi: ", - "Show related videos: ": "Mostra video correlati: ", - "Show annotations by default: ": "Mostra le annotazioni in modo predefinito: ", + "preferences_related_videos_label": "Mostra video correlati: ", + "preferences_annotations_label": "Mostra le annotazioni in modo predefinito: ", "Visual preferences": "Preferenze grafiche", - "Player style: ": "Stile riproduttore: ", + "preferences_player_style_label": "Stile riproduttore: ", "Dark mode: ": "Tema scuro: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "scuro", "light": "chiaro", - "Thin mode: ": "Modalità per connessioni lente: ", + "preferences_thin_mode_label": "Modalità per connessioni lente: ", "Subscription preferences": "Preferenze iscrizioni", - "Show annotations by default for subscribed channels: ": "Mostrare annotazioni in modo predefinito per i canali sottoscritti: ", + "preferences_annotations_subscribed_label": "Mostrare annotazioni in modo predefinito per i canali sottoscritti: ", "Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ", - "Number of videos shown in feed: ": "Numero di video da mostrare nelle iscrizioni: ", - "Sort videos by: ": "Ordina i video per: ", + "preferences_max_results_label": "Numero di video da mostrare nelle iscrizioni: ", + "preferences_sort_label": "Ordina i video per: ", "published": "data di pubblicazione", "published - reverse": "data di pubblicazione - decrescente", "alphabetically": "ordine alfabetico", @@ -97,8 +97,8 @@ "channel name - reverse": "nome del canale - decrescente", "Only show latest video from channel: ": "Mostra solo il video più recente del canale: ", "Only show latest unwatched video from channel: ": "Mostra solo il video più recente non guardato del canale: ", - "Only show unwatched: ": "Mostra solo i video non guardati: ", - "Only show notifications (if there are any): ": "Mostra solo le notifiche (se presenti): ", + "preferences_unseen_only_label": "Mostra solo i video non guardati: ", + "preferences_notifications_only_label": "Mostra solo le notifiche (se presenti): ", "Enable web notifications": "Attiva le notifiche web", "`x` uploaded a video": "`x` ha caricato un video", "`x` is live": "`x` è in diretta", @@ -111,8 +111,8 @@ "Watch history": "Cronologia dei video", "Delete account": "Elimina l'account", "Administrator preferences": "Preferenze amministratore", - "Default homepage: ": "Pagina principale predefinita: ", - "Feed menu: ": "Menu iscrizioni: ", + "preferences_default_home_label": "Pagina principale predefinita: ", + "preferences_feed_menu_label": "Menu iscrizioni: ", "Top enabled: ": "Top abilitato: ", "CAPTCHA enabled: ": "CAPTCHA attivati: ", "Login enabled: ": "Accesso attivato: ", @@ -364,7 +364,7 @@ "Top": "Top", "About": "Al riguardo", "Rating: ": "Punteggio: ", - "Language: ": "Lingua: ", + "preferences_locale_label": "Lingua: ", "View as playlist": "Vedi come playlist", "Default": "Predefinito", "Music": "Musica", diff --git a/locales/ja.json b/locales/ja.json index b73addfb..4c3dd9cf 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -61,38 +61,38 @@ "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: ": "デフォルトのコメント: ", + "preferences_video_loop_label": "常にループ: ", + "preferences_autoplay_label": "自動再生: ", + "preferences_continue_label": "デフォルトで次を再生: ", + "preferences_continue_autoplay_label": "次の動画を自動再生: ", + "preferences_listen_label": "デフォルトでオーディオモードを使用: ", + "preferences_local_label": "動画をプロキシーに通す: ", + "preferences_speed_label": "デフォルトの再生速度: ", + "preferences_quality_label": "優先する画質: ", + "preferences_volume_label": "プレイヤーの音量: ", + "preferences_comments_label": "デフォルトのコメント: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "デフォルトの字幕: ", + "preferences_captions_label": "デフォルトの字幕: ", "Fallback captions: ": "フォールバック時の字幕: ", - "Show related videos: ": "関連動画を表示: ", - "Show annotations by default: ": "デフォルトでアノテーションを表示: ", - "Automatically extend video description: ": "動画の説明文を自動的に拡張: ", - "Interactive 360 degree videos: ": "対話的な360°動画: ", + "preferences_related_videos_label": "関連動画を表示: ", + "preferences_annotations_label": "デフォルトでアノテーションを表示: ", + "preferences_extend_desc_label": "動画の説明文を自動的に拡張: ", + "preferences_vr_mode_label": "対話的な360°動画: ", "Visual preferences": "外観設定", - "Player style: ": "プレイヤースタイル: ", + "preferences_player_style_label": "プレイヤースタイル: ", "Dark mode: ": "ダークモード: ", - "Theme: ": "テーマ: ", + "preferences_dark_mode_label": "テーマ: ", "dark": "ダーク", "light": "ライト", - "Thin mode: ": "最小モード: ", + "preferences_thin_mode_label": "最小モード: ", "Miscellaneous preferences": "雑設定", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自動インスタンスの移転(redirect.invidious.ioにフォールバック): ", + "preferences_automatic_instance_redirect_label": "自動インスタンスの移転(redirect.invidious.ioにフォールバック): ", "Subscription preferences": "登録チャンネル設定", - "Show annotations by default for subscribed channels: ": "デフォルトで登録チャンネルのアノテーションを表示しますか? ", + "preferences_annotations_subscribed_label": "デフォルトで登録チャンネルのアノテーションを表示しますか? ", "Redirect homepage to feed: ": "ホームからフィードにリダイレクト: ", - "Number of videos shown in feed: ": "フィードに表示する動画の量: ", - "Sort videos by: ": "動画を並び替え: ", + "preferences_max_results_label": "フィードに表示する動画の量: ", + "preferences_sort_label": "動画を並び替え: ", "published": "投稿日", "published - reverse": "投稿日 - 逆順", "alphabetically": "アルファベット", @@ -101,8 +101,8 @@ "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): ": "通知のみを表示 (ある場合): ", + "preferences_unseen_only_label": "未視聴のみを表示: ", + "preferences_notifications_only_label": "通知のみを表示 (ある場合): ", "Enable web notifications": "ウェブ通知を有効化", "`x` uploaded a video": "`x` が動画を投稿しました", "`x` is live": "`x` がライブ中です", @@ -115,9 +115,9 @@ "Watch history": "再生履歴", "Delete account": "アカウントを削除", "Administrator preferences": "管理者設定", - "Default homepage: ": "デフォルトのホーム: ", - "Feed menu: ": "フィードメニュー: ", - "Show nickname on top: ": "ニックネームを一番上に表示する: ", + "preferences_default_home_label": "デフォルトのホーム: ", + "preferences_feed_menu_label": "フィードメニュー: ", + "preferences_show_nick_label": "ニックネームを一番上に表示する: ", "Top enabled: ": "トップページを有効化: ", "CAPTCHA enabled: ": "CAPTCHA を有効化: ", "Login enabled: ": "ログインを有効化: ", @@ -374,7 +374,7 @@ "Top": "トップ", "About": "このサービスについて", "Rating: ": "評価: ", - "Language: ": "言語: ", + "preferences_locale_label": "言語: ", "View as playlist": "再生リストで見る", "Default": "デフォルト", "Music": "音楽", diff --git a/locales/ko.json b/locales/ko.json index 18f16cc7..055cc6e8 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -1,35 +1,35 @@ { - "Sort videos by: ": "동영상 정렬 기준: ", - "Number of videos shown in feed: ": "피드에 표시된 동영상 수: ", + "preferences_sort_label": "동영상 정렬 기준: ", + "preferences_max_results_label": "피드에 표시된 동영상 수: ", "Redirect homepage to feed: ": "피드로 홈페이지 리디렉션: ", - "Show annotations by default for subscribed channels: ": "구독한 채널에 기본적으로 특수효과를 표시하시겠습니까? ", + "preferences_annotations_subscribed_label": "구독한 채널에 기본적으로 특수효과를 표시하시겠습니까? ", "Subscription preferences": "구독 설정", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "자동 인스턴스 리디렉션 (redirect.invidious.io로 대체): ", - "Thin mode: ": "단순 모드: ", + "preferences_automatic_instance_redirect_label": "자동 인스턴스 리디렉션 (redirect.invidious.io로 대체): ", + "preferences_thin_mode_label": "단순 모드: ", "light": "라이트", "dark": "다크", - "Theme: ": "테마: ", + "preferences_dark_mode_label": "테마: ", "Dark mode: ": "다크 모드: ", - "Player style: ": "플레이어 스타일: ", + "preferences_player_style_label": "플레이어 스타일: ", "Visual preferences": "시각 설정", - "Interactive 360 degree videos: ": "인터랙티브 360도 비디오: ", - "Automatically extend video description: ": "자동으로 비디오 설명 확장: ", - "Show annotations by default: ": "기본적으로 주석 표시: ", - "Show related videos: ": "관련 동영상 보기: ", + "preferences_vr_mode_label": "인터랙티브 360도 비디오: ", + "preferences_extend_desc_label": "자동으로 비디오 설명 확장: ", + "preferences_annotations_label": "기본적으로 주석 표시: ", + "preferences_related_videos_label": "관련 동영상 보기: ", "Fallback captions: ": "대체 자막: ", - "Default captions: ": "기본 자막: ", + "preferences_captions_label": "기본 자막: ", "reddit": "Reddit", "youtube": "YouTube", - "Default comments: ": "기본 댓글: ", - "Player volume: ": "플레이어 볼륨: ", - "Preferred video quality: ": "선호하는 비디오 품질: ", - "Default speed: ": "기본 속도: ", - "Proxy videos: ": "비디오를 프록시: ", - "Listen by default: ": "기본적으로 듣기: ", - "Autoplay next video: ": "다음 동영상 자동재생 ", - "Play next by default: ": "기본적으로 다음 재생: ", - "Autoplay: ": "자동재생: ", - "Always loop: ": "항상 반복: ", + "preferences_comments_label": "기본 댓글: ", + "preferences_volume_label": "플레이어 볼륨: ", + "preferences_quality_label": "선호하는 비디오 품질: ", + "preferences_speed_label": "기본 속도: ", + "preferences_local_label": "비디오를 프록시: ", + "preferences_listen_label": "기본적으로 듣기: ", + "preferences_continue_autoplay_label": "다음 동영상 자동재생 ", + "preferences_continue_label": "기본적으로 다음 재생: ", + "preferences_autoplay_label": "자동재생: ", + "preferences_video_loop_label": "항상 반복: ", "Player preferences": "플레이어 설정", "Preferences": "설정", "Google verification code": "구글 인증 코드", @@ -171,9 +171,9 @@ "Login enabled: ": "로그인 활성화: ", "CAPTCHA enabled: ": "CAPTCHA 활성화: ", "Top enabled: ": "Top 활성화: ", - "Show nickname on top: ": "상단에 닉네임 표시: ", - "Feed menu: ": "피드 메뉴: ", - "Default homepage: ": "기본 홈페이지: ", + "preferences_show_nick_label": "상단에 닉네임 표시: ", + "preferences_feed_menu_label": "피드 메뉴: ", + "preferences_default_home_label": "기본 홈페이지: ", "Administrator preferences": "관리자 설정", "Delete account": "계정 삭제", "Watch history": "시청 기록", @@ -186,8 +186,8 @@ "`x` is live": "`x` 이(가) 라이브 중입니다", "`x` uploaded a video": "`x` 동영상 게시됨", "Enable web notifications": "웹 알림 활성화", - "Only show notifications (if there are any): ": "알림만 표시 (있는 경우): ", - "Only show unwatched: ": "시청하지 않은 것만 표시: ", + "preferences_notifications_only_label": "알림만 표시 (있는 경우): ", + "preferences_unseen_only_label": "시청하지 않은 것만 표시: ", "Only show latest unwatched video from channel: ": "채널의 시청하지 않은 최신 동영상만 표시: ", "Only show latest video from channel: ": "채널의 최신 동영상만 표시: ", "channel name - reverse": "채널 이름 - 역순", @@ -226,7 +226,7 @@ "Download as: ": "다음으로 다운로드: ", "Download": "다운로드", "Search": "검색", - "Language: ": "언어: ", + "preferences_locale_label": "언어: ", "Malayalam": "말라얄람어", "Malay": "말레이어", "Malagasy": "말라가시어", diff --git a/locales/lt.json b/locales/lt.json index 8b17e65a..9427f78c 100644 --- a/locales/lt.json +++ b/locales/lt.json @@ -61,38 +61,38 @@ "Google verification code": "Google patvirtinimo kodas", "Preferences": "Pasirinktys", "Player preferences": "Grotuvo pasirinktys", - "Always loop: ": "Visada kartoti: ", - "Autoplay: ": "Leisti automatiškai: ", - "Play next by default: ": "Leisti sekantį automatiškai kaip nustatyta: ", - "Autoplay next video: ": "Automatiškai leisti sekantį vaizdo įrašą: ", - "Listen by default: ": "Klausytis kaip nustatyta: ", - "Proxy videos: ": "Vaizdo įrašams naudoti proxy: ", - "Default speed: ": "Numatytasis greitis: ", - "Preferred video quality: ": "Pageidaujama vaizdo kokybė: ", - "Player volume: ": "Grotuvo garsas: ", - "Default comments: ": "Numatytieji komentarai: ", + "preferences_video_loop_label": "Visada kartoti: ", + "preferences_autoplay_label": "Leisti automatiškai: ", + "preferences_continue_label": "Leisti sekantį automatiškai kaip nustatyta: ", + "preferences_continue_autoplay_label": "Automatiškai leisti sekantį vaizdo įrašą: ", + "preferences_listen_label": "Klausytis kaip nustatyta: ", + "preferences_local_label": "Vaizdo įrašams naudoti proxy: ", + "preferences_speed_label": "Numatytasis greitis: ", + "preferences_quality_label": "Pageidaujama vaizdo kokybė: ", + "preferences_volume_label": "Grotuvo garsas: ", + "preferences_comments_label": "Numatytieji komentarai: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Numatytieji subtitrai: ", + "preferences_captions_label": "Numatytieji subtitrai: ", "Fallback captions: ": "Atsarginiai subtitrai: ", - "Show related videos: ": "Rodyti susijusius vaizdo įrašus: ", - "Show annotations by default: ": "Rodyti anotacijas pagal nutylėjimą: ", - "Automatically extend video description: ": "Automatiškai išplėsti vaizdo įrašo aprašymą: ", - "Interactive 360 degree videos: ": "Interaktyvūs 360 laipsnių vaizdo įrašai: ", + "preferences_related_videos_label": "Rodyti susijusius vaizdo įrašus: ", + "preferences_annotations_label": "Rodyti anotacijas pagal nutylėjimą: ", + "preferences_extend_desc_label": "Automatiškai išplėsti vaizdo įrašo aprašymą: ", + "preferences_vr_mode_label": "Interaktyvūs 360 laipsnių vaizdo įrašai: ", "Visual preferences": "Vizualinės nuostatos", - "Player style: ": "Vaizdo grotuvo stilius: ", + "preferences_player_style_label": "Vaizdo grotuvo stilius: ", "Dark mode: ": "Tamsus rėžimas: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "tamsi", "light": "šviesi", - "Thin mode: ": "Sugretintas rėžimas: ", + "preferences_thin_mode_label": "Sugretintas rėžimas: ", "Miscellaneous preferences": "Įvairios nuostatos", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatinis šaltinio nukreipimas (atsarginis nukreipimas į redirect.Invidous.io): ", + "preferences_automatic_instance_redirect_label": "Automatinis šaltinio nukreipimas (atsarginis nukreipimas į redirect.Invidous.io): ", "Subscription preferences": "Prenumeratų nuostatos", - "Show annotations by default for subscribed channels: ": "Prenumeruojamiems kanalams subtitrus rodyti pagal nutylėjimą: ", + "preferences_annotations_subscribed_label": "Prenumeruojamiems kanalams subtitrus rodyti pagal nutylėjimą: ", "Redirect homepage to feed: ": "Peradresuoti pagrindinį puslapį į kanalų sąrašą: ", - "Number of videos shown in feed: ": "Vaizdo įrašų kiekis kanalų sąraše: ", - "Sort videos by: ": "Rūšiuoti vaizdo įrašus pagal: ", + "preferences_max_results_label": "Vaizdo įrašų kiekis kanalų sąraše: ", + "preferences_sort_label": "Rūšiuoti vaizdo įrašus pagal: ", "published": "paskelbta", "published - reverse": "paskelbta - atvirkštine tvarka", "alphabetically": "pagal abėcėlę", @@ -101,8 +101,8 @@ "channel name - reverse": "kanalo pavadinimas - atvirkštine tvarka", "Only show latest video from channel: ": "Rodyti tik naujausius vaizdo įrašus iš kanalo: ", "Only show latest unwatched video from channel: ": "Rodyti tik naujausius nežiūrėtus vaizdo įrašus iš kanalo: ", - "Only show unwatched: ": "Rodyti tik nežiūrėtus: ", - "Only show notifications (if there are any): ": "Rodyti tik pranešimus (jei yra): ", + "preferences_unseen_only_label": "Rodyti tik nežiūrėtus: ", + "preferences_notifications_only_label": "Rodyti tik pranešimus (jei yra): ", "Enable web notifications": "Įgalinti žiniatinklio pranešimus", "`x` uploaded a video": "`x` įkėlė vaizdo įrašą", "`x` is live": "`x` transliuoja tiesiogiai", @@ -115,9 +115,9 @@ "Watch history": "Žiūrėjimo istorija", "Delete account": "Ištrinti paskyrą", "Administrator preferences": "Administratoriaus nuostatos", - "Default homepage: ": "Numatytasis pagrindinis puslapis ", - "Feed menu: ": "Kanalų sąrašo meniu: ", - "Show nickname on top: ": "Rodyti slapyvardį viršuje: ", + "preferences_default_home_label": "Numatytasis pagrindinis puslapis ", + "preferences_feed_menu_label": "Kanalų sąrašo meniu: ", + "preferences_show_nick_label": "Rodyti slapyvardį viršuje: ", "Top enabled: ": "Įgalinti viršų: ", "CAPTCHA enabled: ": "Įgalinta CAPTCHA: ", "Login enabled: ": "Įgalintas prisijungimas: ", @@ -374,7 +374,7 @@ "Top": "Top", "About": "Apie", "Rating: ": "Reitingas: ", - "Language: ": "Kalba: ", + "preferences_locale_label": "Kalba: ", "View as playlist": "Žiūrėti kaip grojaraštį", "Default": "Numatytasis", "Music": "Muzika", diff --git a/locales/nb-NO.json b/locales/nb-NO.json index 1a6dcb38..a7828015 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -61,38 +61,38 @@ "Google verification code": "Google-bekreftelseskode", "Preferences": "Innstillinger", "Player preferences": "Avspillerinnstillinger", - "Always loop: ": "Alltid gjenta: ", - "Autoplay: ": "Autoavspilling: ", - "Play next by default: ": "Spill neste som forvalg: ", - "Autoplay next video: ": "Autospill neste video: ", - "Listen by default: ": "Lytt som forvalg: ", - "Proxy videos: ": "Mellomtjen videoer? ", - "Default speed: ": "Forvalgt hastighet: ", - "Preferred video quality: ": "Foretrukket videokvalitet: ", - "Player volume: ": "Avspillerlydstyrke: ", - "Default comments: ": "Forvalgte kommentarer: ", + "preferences_video_loop_label": "Alltid gjenta: ", + "preferences_autoplay_label": "Autoavspilling: ", + "preferences_continue_label": "Spill neste som forvalg: ", + "preferences_continue_autoplay_label": "Autospill neste video: ", + "preferences_listen_label": "Lytt som forvalg: ", + "preferences_local_label": "Mellomtjen videoer? ", + "preferences_speed_label": "Forvalgt hastighet: ", + "preferences_quality_label": "Foretrukket videokvalitet: ", + "preferences_volume_label": "Avspillerlydstyrke: ", + "preferences_comments_label": "Forvalgte kommentarer: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Forvalgte undertitler: ", + "preferences_captions_label": "Forvalgte undertitler: ", "Fallback captions: ": "Tilbakefallsundertitler: ", - "Show related videos: ": "Vis relaterte videoer? ", - "Show annotations by default: ": "Vis merknader som forvalg? ", - "Automatically extend video description: ": "Utvid videobeskrivelse automatisk: ", - "Interactive 360 degree videos: ": "Interaktive 360-gradersfilmer: ", + "preferences_related_videos_label": "Vis relaterte videoer? ", + "preferences_annotations_label": "Vis merknader som forvalg? ", + "preferences_extend_desc_label": "Utvid videobeskrivelse automatisk: ", + "preferences_vr_mode_label": "Interaktive 360-gradersfilmer: ", "Visual preferences": "Visuelle innstillinger", - "Player style: ": "Avspillerstil: ", + "preferences_player_style_label": "Avspillerstil: ", "Dark mode: ": "Mørk drakt: ", - "Theme: ": "Drakt: ", + "preferences_dark_mode_label": "Drakt: ", "dark": "Mørk", "light": "Lys", - "Thin mode: ": "Tynt modus: ", + "preferences_thin_mode_label": "Tynt modus: ", "Miscellaneous preferences": "Ulike innstillinger", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatisk instansomdirigering (faller tilbake til redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Automatisk instansomdirigering (faller tilbake til redirect.invidious.io): ", "Subscription preferences": "Abonnementsinnstillinger", - "Show annotations by default for subscribed channels: ": "Vis merknader som forvalg for kanaler det abonneres på? ", + "preferences_annotations_subscribed_label": "Vis merknader som forvalg for kanaler det abonneres på? ", "Redirect homepage to feed: ": "Videresend hjemmeside til kilde: ", - "Number of videos shown in feed: ": "Antall videoer å vise i kilde: ", - "Sort videos by: ": "Sorter videoer etter: ", + "preferences_max_results_label": "Antall videoer å vise i kilde: ", + "preferences_sort_label": "Sorter videoer etter: ", "published": "publisert", "published - reverse": "publisert - motsatt", "alphabetically": "alfabetisk", @@ -101,8 +101,8 @@ "channel name - reverse": "kanalnavn - motsatt", "Only show latest video from channel: ": "Kun vis siste video fra kanal: ", "Only show latest unwatched video from channel: ": "Kun vis siste usette video fra kanal: ", - "Only show unwatched: ": "Kun vis usette: ", - "Only show notifications (if there are any): ": "Kun vis merknader (hvis det er noen): ", + "preferences_unseen_only_label": "Kun vis usette: ", + "preferences_notifications_only_label": "Kun vis merknader (hvis det er noen): ", "Enable web notifications": "Skru på nettmerknader", "`x` uploaded a video": "`x` lastet opp en video", "`x` is live": "`x` er pålogget", @@ -115,9 +115,9 @@ "Watch history": "Visningshistorikk", "Delete account": "Slett konto", "Administrator preferences": "Administratorinnstillinger", - "Default homepage: ": "Forvalgt hjemmeside: ", - "Feed menu: ": "Kilde-meny: ", - "Show nickname on top: ": "Vis kallenavn på toppen: ", + "preferences_default_home_label": "Forvalgt hjemmeside: ", + "preferences_feed_menu_label": "Kilde-meny: ", + "preferences_show_nick_label": "Vis kallenavn på toppen: ", "Top enabled: ": "Topp påskrudd? ", "CAPTCHA enabled: ": "CAPTCHA påskrudd? ", "Login enabled: ": "Innlogging påskrudd? ", @@ -374,7 +374,7 @@ "Top": "Topp", "About": "Om", "Rating: ": "Vurdering: ", - "Language: ": "Språk: ", + "preferences_locale_label": "Språk: ", "View as playlist": "Vis som spilleliste", "Default": "Forvalg", "Music": "Musikk", diff --git a/locales/nl.json b/locales/nl.json index 69fdd5d6..9699377d 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -61,36 +61,36 @@ "Google verification code": "Google-verificatiecode", "Preferences": "Instellingen", "Player preferences": "Spelerinstellingen", - "Always loop: ": "Altijd herhalen: ", - "Autoplay: ": "Automatisch afspelen: ", - "Play next by default: ": "Standaard volgende video afspelen: ", - "Autoplay next video: ": "Volgende video automatisch afspelen: ", - "Listen by default: ": "Standaard luisteren: ", - "Proxy videos: ": "Video's afspelen via proxy? ", - "Default speed: ": "Standaard afspeelsnelheid: ", - "Preferred video quality: ": "Voorkeurskwaliteit: ", - "Player volume: ": "Spelervolume: ", - "Default comments: ": "Reacties tonen van: ", + "preferences_video_loop_label": "Altijd herhalen: ", + "preferences_autoplay_label": "Automatisch afspelen: ", + "preferences_continue_label": "Standaard volgende video afspelen: ", + "preferences_continue_autoplay_label": "Volgende video automatisch afspelen: ", + "preferences_listen_label": "Standaard luisteren: ", + "preferences_local_label": "Video's afspelen via proxy? ", + "preferences_speed_label": "Standaard afspeelsnelheid: ", + "preferences_quality_label": "Voorkeurskwaliteit: ", + "preferences_volume_label": "Spelervolume: ", + "preferences_comments_label": "Reacties tonen van: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Standaard ondertiteling: ", + "preferences_captions_label": "Standaard ondertiteling: ", "Fallback captions: ": "Alternatieve ondertiteling: ", - "Show related videos: ": "Gerelateerde video's tonen? ", - "Show annotations by default: ": "Standaard annotaties tonen? ", - "Automatically extend video description: ": "Breid videobeschrijving automatisch uit: ", - "Interactive 360 degree videos: ": "Interactieve 360-graden-video's ", + "preferences_related_videos_label": "Gerelateerde video's tonen? ", + "preferences_annotations_label": "Standaard annotaties tonen? ", + "preferences_extend_desc_label": "Breid videobeschrijving automatisch uit: ", + "preferences_vr_mode_label": "Interactieve 360-graden-video's ", "Visual preferences": "Visuele instellingen", - "Player style: ": "Speler vormgeving ", + "preferences_player_style_label": "Speler vormgeving ", "Dark mode: ": "Donkere modus: ", - "Theme: ": "Thema: ", + "preferences_dark_mode_label": "Thema: ", "dark": "donker", "light": "licht", - "Thin mode: ": "Smalle modus: ", + "preferences_thin_mode_label": "Smalle modus: ", "Subscription preferences": "Abonnementsinstellingen", - "Show annotations by default for subscribed channels: ": "Standaard annotaties tonen voor geabonneerde kanalen? ", + "preferences_annotations_subscribed_label": "Standaard annotaties tonen voor geabonneerde kanalen? ", "Redirect homepage to feed: ": "Startpagina omleiden naar feed: ", - "Number of videos shown in feed: ": "Aantal te tonen video's in feed: ", - "Sort videos by: ": "Video's sorteren op: ", + "preferences_max_results_label": "Aantal te tonen video's in feed: ", + "preferences_sort_label": "Video's sorteren op: ", "published": "publicatiedatum", "published - reverse": "publicatiedatum - omgekeerd", "alphabetically": "alfabetische volgorde", @@ -99,8 +99,8 @@ "channel name - reverse": "kanaalnaam - omgekeerd", "Only show latest video from channel: ": "Alleen nieuwste video van kanaal tonen: ", "Only show latest unwatched video from channel: ": "Alleen nieuwste niet-bekeken video van kanaal tonen: ", - "Only show unwatched: ": "Alleen niet-bekeken videos tonen: ", - "Only show notifications (if there are any): ": "Alleen meldingen tonen (als die er zijn): ", + "preferences_unseen_only_label": "Alleen niet-bekeken videos tonen: ", + "preferences_notifications_only_label": "Alleen meldingen tonen (als die er zijn): ", "Enable web notifications": "Systemmeldingen inschakelen", "`x` uploaded a video": "`x` heeft een video geüpload", "`x` is live": "`x` zendt nu live uit", @@ -113,8 +113,8 @@ "Watch history": "Kijkgeschiedenis", "Delete account": "Account verwijderen", "Administrator preferences": "Beheerdersinstellingen", - "Default homepage: ": "Standaard startpagina: ", - "Feed menu: ": "Feedmenu: ", + "preferences_default_home_label": "Standaard startpagina: ", + "preferences_feed_menu_label": "Feedmenu: ", "Top enabled: ": "Bovenkant inschakelen? ", "CAPTCHA enabled: ": "CAPTCHA gebruiken? ", "Login enabled: ": "Inloggen toestaan? ", @@ -368,7 +368,7 @@ "Top": "Top", "About": "Over", "Rating: ": "Waardering: ", - "Language: ": "Taal: ", + "preferences_locale_label": "Taal: ", "View as playlist": "Tonen als afspeellijst", "Default": "Standaard", "Music": "Muziek", diff --git a/locales/pl.json b/locales/pl.json index 23821405..252a8dea 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -61,38 +61,38 @@ "Google verification code": "Kod weryfikacyjny Google", "Preferences": "Preferencje", "Player preferences": "Ustawienia odtwarzacza", - "Always loop: ": "Zawsze zapętlaj: ", - "Autoplay: ": "Autoodtwarzanie: ", - "Play next by default: ": "Domyślnie odtwarzaj następny: ", - "Autoplay next video: ": "Odtwórz następny film: ", - "Listen by default: ": "Tryb dźwiękowy: ", - "Proxy videos: ": "Filmy przez proxy? ", - "Default speed: ": "Domyślna prędkość: ", - "Preferred video quality: ": "Preferowana jakość filmów: ", - "Player volume: ": "Głośność odtwarzacza: ", - "Default comments: ": "Domyślne komentarze: ", + "preferences_video_loop_label": "Zawsze zapętlaj: ", + "preferences_autoplay_label": "Autoodtwarzanie: ", + "preferences_continue_label": "Domyślnie odtwarzaj następny: ", + "preferences_continue_autoplay_label": "Odtwórz następny film: ", + "preferences_listen_label": "Tryb dźwiękowy: ", + "preferences_local_label": "Filmy przez proxy? ", + "preferences_speed_label": "Domyślna prędkość: ", + "preferences_quality_label": "Preferowana jakość filmów: ", + "preferences_volume_label": "Głośność odtwarzacza: ", + "preferences_comments_label": "Domyślne komentarze: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "Domyślne napisy: ", + "preferences_captions_label": "Domyślne napisy: ", "Fallback captions: ": "Zastępcze napisy: ", - "Show related videos: ": "Pokaż powiązane filmy? ", - "Show annotations by default: ": "Domyślnie pokazuj adnotacje: ", - "Automatically extend video description: ": "Automatycznie rozwijaj opisy filmów: ", - "Interactive 360 degree videos: ": "Interaktywne filmy 360 stopni: ", + "preferences_related_videos_label": "Pokaż powiązane filmy? ", + "preferences_annotations_label": "Domyślnie pokazuj adnotacje: ", + "preferences_extend_desc_label": "Automatycznie rozwijaj opisy filmów: ", + "preferences_vr_mode_label": "Interaktywne filmy 360 stopni: ", "Visual preferences": "Preferencje Wizualne", - "Player style: ": "Styl odtwarzacza: ", + "preferences_player_style_label": "Styl odtwarzacza: ", "Dark mode: ": "Ciemny motyw: ", - "Theme: ": "Motyw: ", + "preferences_dark_mode_label": "Motyw: ", "dark": "ciemny", "light": "jasny", - "Thin mode: ": "Tryb minimalny: ", + "preferences_thin_mode_label": "Tryb minimalny: ", "Miscellaneous preferences": "Różne preferencje", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Automatyczne przekierowanie instancji (powrót do redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Automatyczne przekierowanie instancji (powrót do redirect.invidious.io): ", "Subscription preferences": "Preferencje subskrybcji", - "Show annotations by default for subscribed channels: ": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ", + "preferences_annotations_subscribed_label": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ", "Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ", - "Number of videos shown in feed: ": "Liczba filmów widoczna na stronie subskrybcji: ", - "Sort videos by: ": "Sortuj filmy: ", + "preferences_max_results_label": "Liczba filmów widoczna na stronie subskrybcji: ", + "preferences_sort_label": "Sortuj filmy: ", "published": "po czasie publikacji", "published - reverse": "po czasie publikacji od najstarszych", "alphabetically": "alfabetycznie", @@ -101,8 +101,8 @@ "channel name - reverse": "po nazwie kanału od tyłu", "Only show latest video from channel: ": "Pokazuj tylko najnowszy film z kanału: ", "Only show latest unwatched video from channel: ": "Pokazuj tylko najnowszy nie obejrzany film z kanału: ", - "Only show unwatched: ": "Pokazuj tylko nie obejrzane: ", - "Only show notifications (if there are any): ": "Pokazuj tylko powiadomienia (jeśli są): ", + "preferences_unseen_only_label": "Pokazuj tylko nie obejrzane: ", + "preferences_notifications_only_label": "Pokazuj tylko powiadomienia (jeśli są): ", "Enable web notifications": "Włącz powiadomienia", "`x` uploaded a video": "`x` dodał film", "`x` is live": "'x ' jest na żywo", @@ -115,9 +115,9 @@ "Watch history": "Historia", "Delete account": "Usuń konto", "Administrator preferences": "Preferencje administratora", - "Default homepage: ": "Domyślna strona główna: ", - "Feed menu: ": "Menu aktualności ", - "Show nickname on top: ": "Pokaż pseudonim na górze: ", + "preferences_default_home_label": "Domyślna strona główna: ", + "preferences_feed_menu_label": "Menu aktualności ", + "preferences_show_nick_label": "Pokaż pseudonim na górze: ", "Top enabled: ": "\"Top\" aktywne: ", "CAPTCHA enabled: ": "CAPTCHA aktywna? ", "Login enabled: ": "Logowanie włączone? ", @@ -373,7 +373,7 @@ "Top": "Top", "About": "Informacje", "Rating: ": "Ocena: ", - "Language: ": "Język: ", + "preferences_locale_label": "Język: ", "View as playlist": "Obejrzyj w playliście", "Default": "Domyślnie", "Music": "Muzyka", diff --git a/locales/pt-BR.json b/locales/pt-BR.json index a3038b83..16475807 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -61,38 +61,38 @@ "Google verification code": "Código de verificação do Google", "Preferences": "Preferências", "Player preferences": "Preferências do reprodutor", - "Always loop: ": "Repetir sempre: ", - "Autoplay: ": "Reprodução automática: ", - "Play next by default: ": "Sempre reproduzir próximo: ", - "Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ", - "Listen by default: ": "Apenas áudio por padrão: ", - "Proxy videos: ": "Usar proxy nos vídeos: ", - "Default speed: ": "Velocidade padrão: ", - "Preferred video quality: ": "Qualidade de vídeo preferida: ", - "Player volume: ": "Volume de reprodução: ", - "Default comments: ": "Preferência de comentários: ", + "preferences_video_loop_label": "Repetir sempre: ", + "preferences_autoplay_label": "Reprodução automática: ", + "preferences_continue_label": "Sempre reproduzir próximo: ", + "preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ", + "preferences_listen_label": "Apenas áudio por padrão: ", + "preferences_local_label": "Usar proxy nos vídeos: ", + "preferences_speed_label": "Velocidade padrão: ", + "preferences_quality_label": "Qualidade de vídeo preferida: ", + "preferences_volume_label": "Volume de reprodução: ", + "preferences_comments_label": "Preferência de comentários: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Preferência de legendas: ", + "preferences_captions_label": "Preferência de legendas: ", "Fallback captions: ": "Legendas alternativas: ", - "Show related videos: ": "Mostrar vídeos relacionados: ", - "Show annotations by default: ": "Sempre mostrar anotações: ", - "Automatically extend video description: ": "Estenda automaticamente a descrição do vídeo: ", - "Interactive 360 degree videos: ": "Vídeos interativos de 360 graus: ", + "preferences_related_videos_label": "Mostrar vídeos relacionados: ", + "preferences_annotations_label": "Sempre mostrar anotações: ", + "preferences_extend_desc_label": "Estenda automaticamente a descrição do vídeo: ", + "preferences_vr_mode_label": "Vídeos interativos de 360 graus: ", "Visual preferences": "Preferências visuais", - "Player style: ": "Estilo do tocador: ", + "preferences_player_style_label": "Estilo do tocador: ", "Dark mode: ": "Modo escuro: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "escuro", "light": "claro", - "Thin mode: ": "Modo compacto: ", + "preferences_thin_mode_label": "Modo compacto: ", "Miscellaneous preferences": "Preferências diversas", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirecionamento de instância automática (fallback para redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (fallback para redirect.invidious.io): ", "Subscription preferences": "Preferências de inscrições", - "Show annotations by default for subscribed channels: ": "Sempre mostrar anotações dos vídeos de canais inscritos: ", + "preferences_annotations_subscribed_label": "Sempre mostrar anotações dos vídeos de canais inscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para o feed: ", - "Number of videos shown in feed: ": "Número de vídeos no feed: ", - "Sort videos by: ": "Ordenar vídeos por: ", + "preferences_max_results_label": "Número de vídeos no feed: ", + "preferences_sort_label": "Ordenar vídeos por: ", "published": "publicado", "published - reverse": "publicado - ordem inversa", "alphabetically": "alfabética", @@ -101,8 +101,8 @@ "channel name - reverse": "nome do canal - ordem inversa", "Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ", "Only show latest unwatched video from channel: ": "Mostrar apenas o vídeo mais recente não visualizado do canal: ", - "Only show unwatched: ": "Mostrar apenas vídeos não visualizados: ", - "Only show notifications (if there are any): ": "Mostrar apenas notificações (se existentes): ", + "preferences_unseen_only_label": "Mostrar apenas vídeos não visualizados: ", + "preferences_notifications_only_label": "Mostrar apenas notificações (se existentes): ", "Enable web notifications": "Ativar notificações pela web", "`x` uploaded a video": "`x` publicou um novo vídeo", "`x` is live": "`x` está ao vivo", @@ -115,9 +115,9 @@ "Watch history": "Histórico de reprodução", "Delete account": "Apagar sua conta", "Administrator preferences": "Preferências de administrador", - "Default homepage: ": "Página de início padrão: ", - "Feed menu: ": "Menu do feed: ", - "Show nickname on top: ": "Mostrar o nickname no topo: ", + "preferences_default_home_label": "Página de início padrão: ", + "preferences_feed_menu_label": "Menu do feed: ", + "preferences_show_nick_label": "Mostrar o nickname no topo: ", "Top enabled: ": "Habilitar destaques: ", "CAPTCHA enabled: ": "Habilitar CAPTCHA: ", "Login enabled: ": "Habilitar login: ", @@ -374,7 +374,7 @@ "Top": "No topo", "About": "Sobre", "Rating: ": "Avaliação: ", - "Language: ": "Idioma: ", + "preferences_locale_label": "Idioma: ", "View as playlist": "Ver como lista de reprodução", "Default": "Padrão", "Music": "Músicas", diff --git a/locales/pt-PT.json b/locales/pt-PT.json index 08220d43..5700b1a4 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -61,38 +61,38 @@ "Google verification code": "Código de verificação do Google", "Preferences": "Preferências", "Player preferences": "Preferências do reprodutor", - "Always loop: ": "Repetir sempre: ", - "Autoplay: ": "Reprodução automática: ", - "Play next by default: ": "Reproduzir sempre o próximo: ", - "Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ", - "Listen by default: ": "Apenas áudio: ", - "Proxy videos: ": "Usar proxy nos vídeos: ", - "Default speed: ": "Velocidade preferida: ", - "Preferred video quality: ": "Qualidade de vídeo preferida: ", - "Player volume: ": "Volume da reprodução: ", - "Default comments: ": "Preferência dos comentários: ", + "preferences_video_loop_label": "Repetir sempre: ", + "preferences_autoplay_label": "Reprodução automática: ", + "preferences_continue_label": "Reproduzir sempre o próximo: ", + "preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ", + "preferences_listen_label": "Apenas áudio: ", + "preferences_local_label": "Usar proxy nos vídeos: ", + "preferences_speed_label": "Velocidade preferida: ", + "preferences_quality_label": "Qualidade de vídeo preferida: ", + "preferences_volume_label": "Volume da reprodução: ", + "preferences_comments_label": "Preferência dos comentários: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "Legendas predefinidas: ", + "preferences_captions_label": "Legendas predefinidas: ", "Fallback captions: ": "Legendas alternativas: ", - "Show related videos: ": "Mostrar vídeos relacionados: ", - "Show annotations by default: ": "Mostrar anotações sempre: ", - "Automatically extend video description: ": "Estender automaticamente a descrição do vídeo: ", - "Interactive 360 degree videos: ": "Vídeos interativos de 360 graus: ", + "preferences_related_videos_label": "Mostrar vídeos relacionados: ", + "preferences_annotations_label": "Mostrar anotações sempre: ", + "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ", + "preferences_vr_mode_label": "Vídeos interativos de 360 graus: ", "Visual preferences": "Preferências visuais", - "Player style: ": "Estilo do reprodutor: ", + "preferences_player_style_label": "Estilo do reprodutor: ", "Dark mode: ": "Modo escuro: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "escuro", "light": "claro", - "Thin mode: ": "Modo compacto: ", + "preferences_thin_mode_label": "Modo compacto: ", "Miscellaneous preferences": "Preferências diversas", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ", "Subscription preferences": "Preferências de subscrições", - "Show annotations by default for subscribed channels: ": "Mostrar sempre anotações aos canais subscritos: ", + "preferences_annotations_subscribed_label": "Mostrar sempre anotações aos canais subscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ", - "Number of videos shown in feed: ": "Quantidade de vídeos nas subscrições: ", - "Sort videos by: ": "Ordenar vídeos por: ", + "preferences_max_results_label": "Quantidade de vídeos nas subscrições: ", + "preferences_sort_label": "Ordenar vídeos por: ", "published": "publicado", "published - reverse": "publicado - inverso", "alphabetically": "alfabeticamente", @@ -101,8 +101,8 @@ "channel name - reverse": "nome do canal - inverso", "Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ", "Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ", - "Only show unwatched: ": "Mostrar apenas vídeos não visualizados: ", - "Only show notifications (if there are any): ": "Mostrar apenas notificações (se existirem): ", + "preferences_unseen_only_label": "Mostrar apenas vídeos não visualizados: ", + "preferences_notifications_only_label": "Mostrar apenas notificações (se existirem): ", "Enable web notifications": "Ativar notificações pela web", "`x` uploaded a video": "`x` publicou um novo vídeo", "`x` is live": "`x` está em direto", @@ -115,9 +115,9 @@ "Watch history": "Histórico de reprodução", "Delete account": "Eliminar conta", "Administrator preferences": "Preferências de administrador", - "Default homepage: ": "Página inicial predefinida: ", - "Feed menu: ": "Menu de subscrições: ", - "Show nickname on top: ": "Mostrar nome de utilizador em cima: ", + "preferences_default_home_label": "Página inicial predefinida: ", + "preferences_feed_menu_label": "Menu de subscrições: ", + "preferences_show_nick_label": "Mostrar nome de utilizador em cima: ", "Top enabled: ": "Destaques ativados: ", "CAPTCHA enabled: ": "CAPTCHA ativado: ", "Login enabled: ": "Iniciar sessão ativado: ", @@ -374,7 +374,7 @@ "Top": "Destaques", "About": "Sobre", "Rating: ": "Avaliação: ", - "Language: ": "Idioma: ", + "preferences_locale_label": "Idioma: ", "View as playlist": "Ver como lista de reprodução", "Default": "Predefinido", "Music": "Música", diff --git a/locales/pt.json b/locales/pt.json index 42b7ce98..100bcbb7 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -9,11 +9,11 @@ "Show less": "Mostrar menos", "Show more": "Mostrar mais", "Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no Github.", - "Show nickname on top: ": "Mostrar nome de utilizador em cima: ", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ", + "preferences_show_nick_label": "Mostrar nome de utilizador em cima: ", + "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ", "Miscellaneous preferences": "Preferências diversas", - "Interactive 360 degree videos: ": "Vídeos interativos de 360 graus: ", - "Automatically extend video description: ": "Estender automaticamente a descrição do vídeo: ", + "preferences_vr_mode_label": "Vídeos interativos de 360 graus: ", + "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ", "next_steps_error_message_go_to_youtube": "Ir ao YouTube", "next_steps_error_message": "Pode tentar as seguintes opções: ", "next_steps_error_message_refresh": "Atualizar", @@ -91,8 +91,8 @@ "Top enabled: ": "Destaques ativados: ", "Delete account": "Eliminar conta", "Import/export data": "Importar / exportar dados", - "Show annotations by default: ": "Mostrar anotações sempre: ", - "Play next by default: ": "Reproduzir sempre o próximo: ", + "preferences_annotations_label": "Mostrar anotações sempre: ", + "preferences_continue_label": "Reproduzir sempre o próximo: ", "Sign In": "Iniciar sessão", "Log in/register": "Iniciar sessão/registar", "Delete account?": "Eliminar conta?", @@ -220,8 +220,8 @@ "Registration enabled: ": "Registar ativado: ", "Login enabled: ": "Iniciar sessão ativado: ", "CAPTCHA enabled: ": "CAPTCHA ativado: ", - "Feed menu: ": "Menu de subscrições: ", - "Default homepage: ": "Página inicial predefinida: ", + "preferences_feed_menu_label": "Menu de subscrições: ", + "preferences_default_home_label": "Página inicial predefinida: ", "Administrator preferences": "Preferências de administrador", "Watch history": "Histórico de reprodução", "Manage tokens": "Gerir tokens", @@ -232,8 +232,8 @@ "`x` is live": "`x` está em direto", "`x` uploaded a video": "`x` publicou um novo vídeo", "Enable web notifications": "Ativar notificações pela web", - "Only show notifications (if there are any): ": "Mostrar apenas notificações (se existirem): ", - "Only show unwatched: ": "Mostrar apenas vídeos não visualizados: ", + "preferences_notifications_only_label": "Mostrar apenas notificações (se existirem): ", + "preferences_unseen_only_label": "Mostrar apenas vídeos não visualizados: ", "Only show latest unwatched video from channel: ": "Mostrar apenas vídeos mais recentes não visualizados do canal: ", "Only show latest video from channel: ": "Mostrar apenas o vídeo mais recente do canal: ", "channel name - reverse": "nome do canal - inverso", @@ -242,32 +242,32 @@ "alphabetically": "alfabeticamente", "published - reverse": "publicado - inverso", "published": "publicado", - "Sort videos by: ": "Ordenar vídeos por: ", - "Number of videos shown in feed: ": "Quantidade de vídeos nas subscrições: ", + "preferences_sort_label": "Ordenar vídeos por: ", + "preferences_max_results_label": "Quantidade de vídeos nas subscrições: ", "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ", - "Show annotations by default for subscribed channels: ": "Mostrar sempre anotações aos canais subscritos: ", + "preferences_annotations_subscribed_label": "Mostrar sempre anotações aos canais subscritos: ", "Subscription preferences": "Preferências de subscrições", - "Thin mode: ": "Modo compacto: ", + "preferences_thin_mode_label": "Modo compacto: ", "light": "claro", "dark": "escuro", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "Dark mode: ": "Modo escuro: ", - "Player style: ": "Estilo do reprodutor: ", + "preferences_player_style_label": "Estilo do reprodutor: ", "Visual preferences": "Preferências visuais", - "Show related videos: ": "Mostrar vídeos relacionados: ", + "preferences_related_videos_label": "Mostrar vídeos relacionados: ", "Fallback captions: ": "Legendas alternativas: ", - "Default captions: ": "Legendas predefinidas: ", + "preferences_captions_label": "Legendas predefinidas: ", "reddit": "Reddit", "youtube": "YouTube", - "Default comments: ": "Preferência dos comentários: ", - "Player volume: ": "Volume da reprodução: ", - "Preferred video quality: ": "Qualidade de vídeo preferida: ", - "Default speed: ": "Velocidade preferida: ", - "Proxy videos: ": "Usar proxy nos vídeos: ", - "Listen by default: ": "Apenas áudio: ", - "Autoplay next video: ": "Reproduzir próximo vídeo automaticamente: ", - "Autoplay: ": "Reprodução automática: ", - "Always loop: ": "Repetir sempre: ", + "preferences_comments_label": "Preferência dos comentários: ", + "preferences_volume_label": "Volume da reprodução: ", + "preferences_quality_label": "Qualidade de vídeo preferida: ", + "preferences_speed_label": "Velocidade preferida: ", + "preferences_local_label": "Usar proxy nos vídeos: ", + "preferences_listen_label": "Apenas áudio: ", + "preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ", + "preferences_autoplay_label": "Reprodução automática: ", + "preferences_video_loop_label": "Repetir sempre: ", "Player preferences": "Preferências do reprodutor", "Preferences": "Preferências", "Google verification code": "Código de verificação do Google", @@ -318,7 +318,7 @@ "Gaming": "Jogos", "Music": "Música", "View as playlist": "Ver como lista de reprodução", - "Language: ": "Idioma: ", + "preferences_locale_label": "Idioma: ", "Rating: ": "Avaliação: ", "About": "Sobre", "Popular": "Popular", diff --git a/locales/ro.json b/locales/ro.json index 5abebfdd..804da9a3 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -61,34 +61,34 @@ "Google verification code": "Cod de verificare Google", "Preferences": "Preferințe", "Player preferences": "Setări de redare", - "Always loop: ": "Reluați videoclipul la nesfârșit: ", - "Autoplay: ": "Porniți videoclipurile automat: ", - "Play next by default: ": "Vizionați următoarele videoclipuri în mod implicit: ", - "Autoplay next video: ": "Porniți următorul videoclip automat: ", - "Listen by default: ": "Numai audio: ", - "Proxy videos: ": "Redați videoclipurile printr-un proxy: ", - "Default speed: ": "Viteza de redare implicită: ", - "Preferred video quality: ": "Calitatea videoclipurilor: ", - "Player volume: ": "Volumul videoclipurilor: ", - "Default comments: ": "Sursa comentariilor: ", + "preferences_video_loop_label": "Reluați videoclipul la nesfârșit: ", + "preferences_autoplay_label": "Porniți videoclipurile automat: ", + "preferences_continue_label": "Vizionați următoarele videoclipuri în mod implicit: ", + "preferences_continue_autoplay_label": "Porniți următorul videoclip automat: ", + "preferences_listen_label": "Numai audio: ", + "preferences_local_label": "Redați videoclipurile printr-un proxy: ", + "preferences_speed_label": "Viteza de redare implicită: ", + "preferences_quality_label": "Calitatea videoclipurilor: ", + "preferences_volume_label": "Volumul videoclipurilor: ", + "preferences_comments_label": "Sursa comentariilor: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Subtitrări implicite: ", + "preferences_captions_label": "Subtitrări implicite: ", "Fallback captions: ": "Subtitrări alternative: ", - "Show related videos: ": "Afișați videoclipurile asemănătoare: ", - "Show annotations by default: ": "Afișați adnotările în mod implicit: ", + "preferences_related_videos_label": "Afișați videoclipurile asemănătoare: ", + "preferences_annotations_label": "Afișați adnotările în mod implicit: ", "Visual preferences": "Preferințele site-ului", - "Player style: ": "Stilul player-ului : ", + "preferences_player_style_label": "Stilul player-ului : ", "Dark mode: ": "Modul întunecat : ", - "Theme: ": "Tema : ", + "preferences_dark_mode_label": "Tema : ", "dark": "întunecat", "light": "luminos", - "Thin mode: ": "Mod lejer: ", + "preferences_thin_mode_label": "Mod lejer: ", "Subscription preferences": "Preferințele paginii de abonamente", - "Show annotations by default for subscribed channels: ": "Afișați adnotările în mod implicit pentru canalele la care v-ați abonat: ", + "preferences_annotations_subscribed_label": "Afișați adnotările în mod implicit pentru canalele la care v-ați abonat: ", "Redirect homepage to feed: ": "Redirecționați pagina principală la pagina de abonamente: ", - "Number of videos shown in feed: ": "Numărul de videoclipuri afișate pe pagina de abonamente: ", - "Sort videos by: ": "Sortați videoclipurile în funcție de: ", + "preferences_max_results_label": "Numărul de videoclipuri afișate pe pagina de abonamente: ", + "preferences_sort_label": "Sortați videoclipurile în funcție de: ", "published": "data publicării", "published - reverse": "data publicării - inversată", "alphabetically": "în ordine alfabetică", @@ -97,8 +97,8 @@ "channel name - reverse": "numele canalului - inversat", "Only show latest video from channel: ": "Afișați numai cel mai recent videoclip publicat de canalele la care v-ați abonat: ", "Only show latest unwatched video from channel: ": "Afișați numai cel mai recent videoclip nevizionat publicat de canalele la care v-ați abonat: ", - "Only show unwatched: ": "Afișați numai videoclipurile nevizionate: ", - "Only show notifications (if there are any): ": "Afișați numai notificările (dacă există): ", + "preferences_unseen_only_label": "Afișați numai videoclipurile nevizionate: ", + "preferences_notifications_only_label": "Afișați numai notificările (dacă există): ", "Enable web notifications": "Activați notificările web", "`x` uploaded a video": "`x` a publicat un videoclip", "`x` is live": "`x` este în direct", @@ -111,8 +111,8 @@ "Watch history": "Istoricul videoclipurilor vizionate", "Delete account": "Ștergeți contul", "Administrator preferences": "Preferințele Administratorului", - "Default homepage: ": "Pagina principală implicită: ", - "Feed menu: ": "Preferințe legate de pagina de abonamente: ", + "preferences_default_home_label": "Pagina principală implicită: ", + "preferences_feed_menu_label": "Preferințe legate de pagina de abonamente: ", "Top enabled: ": "Top activat: ", "CAPTCHA enabled: ": "CAPTCHA activat : ", "Login enabled: ": "Autentificare activată : ", @@ -363,7 +363,7 @@ "Top": "Top", "About": "Despre", "Rating: ": "Evaluare: ", - "Language: ": "Limbă: ", + "preferences_locale_label": "Limbă: ", "View as playlist": "Vizualizați ca listă de redare", "Default": "Implicit", "Music": "Muzică", diff --git a/locales/ru.json b/locales/ru.json index f5026908..50d74f86 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -61,38 +61,38 @@ "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: ": "Источник комментариев: ", + "preferences_video_loop_label": "Всегда повторять: ", + "preferences_autoplay_label": "Автовоспроизведение: ", + "preferences_continue_label": "Всегда включать следующее видео? ", + "preferences_continue_autoplay_label": "Автопроигрывание следующего видео: ", + "preferences_listen_label": "Режим «только аудио» по умолчанию: ", + "preferences_local_label": "Проигрывать видео через прокси? ", + "preferences_speed_label": "Скорость видео по умолчанию: ", + "preferences_quality_label": "Предпочтительное качество видео: ", + "preferences_volume_label": "Громкость видео: ", + "preferences_comments_label": "Источник комментариев: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Основной язык субтитров: ", + "preferences_captions_label": "Основной язык субтитров: ", "Fallback captions: ": "Дополнительный язык субтитров: ", - "Show related videos: ": "Показывать похожие видео? ", - "Show annotations by default: ": "Всегда показывать аннотации? ", - "Automatically extend video description: ": "Автоматически раскрывать описание видео: ", - "Interactive 360 degree videos: ": "Интерактивные 360-градусные видео: ", + "preferences_related_videos_label": "Показывать похожие видео? ", + "preferences_annotations_label": "Всегда показывать аннотации? ", + "preferences_extend_desc_label": "Автоматически раскрывать описание видео: ", + "preferences_vr_mode_label": "Интерактивные 360-градусные видео: ", "Visual preferences": "Настройки сайта", - "Player style: ": "Стиль проигрывателя: ", + "preferences_player_style_label": "Стиль проигрывателя: ", "Dark mode: ": "Тёмное оформление: ", - "Theme: ": "Тема: ", + "preferences_dark_mode_label": "Тема: ", "dark": "темная", "light": "светлая", - "Thin mode: ": "Облегчённое оформление: ", + "preferences_thin_mode_label": "Облегчённое оформление: ", "Miscellaneous preferences": "Прочие предпочтения", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Автоматическое перенаправление экземпляра (резервный вариант redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Автоматическое перенаправление экземпляра (резервный вариант redirect.invidious.io): ", "Subscription preferences": "Настройки подписок", - "Show annotations by default for subscribed channels: ": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ", + "preferences_annotations_subscribed_label": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ", "Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ", - "Number of videos shown in feed: ": "Число видео, на которые вы подписаны, в ленте: ", - "Sort videos by: ": "Сортировать видео: ", + "preferences_max_results_label": "Число видео, на которые вы подписаны, в ленте: ", + "preferences_sort_label": "Сортировать видео: ", "published": "по дате публикации", "published - reverse": "по дате публикации в обратном порядке", "alphabetically": "по алфавиту", @@ -101,8 +101,8 @@ "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): ": "Показывать только оповещения, если они есть: ", + "preferences_unseen_only_label": "Показывать только непросмотренные видео: ", + "preferences_notifications_only_label": "Показывать только оповещения, если они есть: ", "Enable web notifications": "Включить уведомления в браузере", "`x` uploaded a video": "`x` разместил видео", "`x` is live": "`x` в прямом эфире", @@ -115,9 +115,9 @@ "Watch history": "История просмотров", "Delete account": "Удалить аккаунт", "Administrator preferences": "Администраторские настройки", - "Default homepage: ": "Главная страница по умолчанию: ", - "Feed menu: ": "Меню ленты видео: ", - "Show nickname on top: ": "Показать ник вверху: ", + "preferences_default_home_label": "Главная страница по умолчанию: ", + "preferences_feed_menu_label": "Меню ленты видео: ", + "preferences_show_nick_label": "Показать ник вверху: ", "Top enabled: ": "Включить топ видео? ", "CAPTCHA enabled: ": "Включить капчу? ", "Login enabled: ": "Включить авторизацию? ", @@ -374,7 +374,7 @@ "Top": "Топ", "About": "О сайте", "Rating: ": "Рейтинг: ", - "Language: ": "Язык: ", + "preferences_locale_label": "Язык: ", "View as playlist": "Смотреть как плейлист", "Default": "По-умолчанию", "Music": "Музыка", diff --git a/locales/sk.json b/locales/sk.json index c4c3b32b..33ccd8ac 100644 --- a/locales/sk.json +++ b/locales/sk.json @@ -49,33 +49,33 @@ "Google verification code": "Overovací kód Google", "Preferences": "Nastavenia", "Player preferences": "Nastavenia prehrávača", - "Always loop: ": "Vždy opakovať: ", - "Autoplay: ": "Automatické prehrávanie: ", - "Autoplay next video: ": "Automatické prehrávanie nasledujúceho videa: ", - "Listen by default: ": "Predvolene počúvať: ", - "Proxy videos: ": "Proxy videá: ", - "Default speed: ": "Predvolená rýchlosť: ", - "Preferred video quality: ": "Preferovaná kvalita videa: ", - "Player volume: ": "Hlasitosť prehrávača: ", - "Default comments: ": "Predvolené komentáre: ", + "preferences_video_loop_label": "Vždy opakovať: ", + "preferences_autoplay_label": "Automatické prehrávanie: ", + "preferences_continue_autoplay_label": "Automatické prehrávanie nasledujúceho videa: ", + "preferences_listen_label": "Predvolene počúvať: ", + "preferences_local_label": "Proxy videá: ", + "preferences_speed_label": "Predvolená rýchlosť: ", + "preferences_quality_label": "Preferovaná kvalita videa: ", + "preferences_volume_label": "Hlasitosť prehrávača: ", + "preferences_comments_label": "Predvolené komentáre: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Predvolené titulky: ", + "preferences_captions_label": "Predvolené titulky: ", "Fallback captions: ": "Náhradné titulky: ", - "Show related videos: ": "Zobraziť súvisiace videá: ", - "Show annotations by default: ": "Predvolene zobraziť anotácie: ", + "preferences_related_videos_label": "Zobraziť súvisiace videá: ", + "preferences_annotations_label": "Predvolene zobraziť anotácie: ", "Visual preferences": "Vizuálne nastavenia", - "Player style: ": "Štýl prehrávača: ", + "preferences_player_style_label": "Štýl prehrávača: ", "Dark mode: ": "Tmavý režim: ", - "Theme: ": "Téma: ", + "preferences_dark_mode_label": "Téma: ", "dark": "tmavá", "light": "svetlá", - "Thin mode: ": "Tenký režim: ", + "preferences_thin_mode_label": "Tenký režim: ", "Subscription preferences": "Nastavenia predplatného", - "Show annotations by default for subscribed channels: ": "Predvolene zobraziť anotácie odoberaných kanálov: ", + "preferences_annotations_subscribed_label": "Predvolene zobraziť anotácie odoberaných kanálov: ", "Redirect homepage to feed: ": "Presmerovanie domovskej stránky na informačný kanál: ", - "Number of videos shown in feed: ": "Počet videí zobrazených v informačnom kanáli: ", - "Sort videos by: ": "Zoradiť videá podľa: ", + "preferences_max_results_label": "Počet videí zobrazených v informačnom kanáli: ", + "preferences_sort_label": "Zoradiť videá podľa: ", "published": "zverejnené (od najnovších)", "published - reverse": "zverejnené (od najstarších)", "alphabetically": "abecedne (A-Z)", @@ -84,8 +84,8 @@ "channel name - reverse": "názov kanála (Z-A)", "Only show latest video from channel: ": "Zobraziť iba najnovšie video z kanála: ", "Only show latest unwatched video from channel: ": "Zobraziť iba najnovšie neprehrané video z kanála: ", - "Only show unwatched: ": "Zobraziť iba neprehrané: ", - "Only show notifications (if there are any): ": "Zobraziť iba upozornenia (ak existujú): ", + "preferences_unseen_only_label": "Zobraziť iba neprehrané: ", + "preferences_notifications_only_label": "Zobraziť iba upozornenia (ak existujú): ", "Enable web notifications": "Povoliť webové upozornenia", "`x` uploaded a video": "`x` nahral(a) video" } diff --git a/locales/sr.json b/locales/sr.json index f275209e..fb00709f 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -61,10 +61,10 @@ "Google verification code": "Google-ов оверни кôд", "Preferences": "Подешавања", "Player preferences": "Подешавања репродуктора", - "Always loop: ": "Увек понављај: ", - "Autoplay: ": "Самопуштање: ", - "Play next by default: ": "Увек подразумевано пуштај следеће: ", - "Autoplay next video: ": "Самопуштање следећег видео записа: ", - "Listen by default: ": "Увек подразумевано укључен само звук: ", - "Proxy videos: ": "Приказ видео записа преко посредника: " + "preferences_video_loop_label": "Увек понављај: ", + "preferences_autoplay_label": "Самопуштање: ", + "preferences_continue_label": "Увек подразумевано пуштај следеће: ", + "preferences_continue_autoplay_label": "Самопуштање следећег видео записа: ", + "preferences_listen_label": "Увек подразумевано укључен само звук: ", + "preferences_local_label": "Приказ видео записа преко посредника: " } diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index 67858a79..75427555 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -58,34 +58,34 @@ "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: ": "Подразумевани коментари: ", + "preferences_video_loop_label": "Увек понављај: ", + "preferences_autoplay_label": "Аутоматско пуштање: ", + "preferences_continue_label": "Увек пуштај следеће: ", + "preferences_continue_autoplay_label": "Аутоматско пуштање следећег видеа: ", + "preferences_listen_label": "Режим слушања као подразумевано: ", + "preferences_local_label": "Пуштање видеа кроз прокси сервер: ", + "preferences_speed_label": "Подразумевана брзина репродукције: ", + "preferences_quality_label": "Претпостављени квалитет видеа: ", + "preferences_volume_label": "Јачина звука: ", + "preferences_comments_label": "Подразумевани коментари: ", "youtube": "са YouTube-а", "reddit": "са редита", - "Default captions: ": "Подразумевани титлови: ", + "preferences_captions_label": "Подразумевани титлови: ", "Fallback captions: ": "Алтернативни титлови: ", - "Show related videos: ": "Прикажи сличне видее: ", - "Show annotations by default: ": "Увек приказуј анотације: ", + "preferences_related_videos_label": "Прикажи сличне видее: ", + "preferences_annotations_label": "Увек приказуј анотације: ", "Visual preferences": "Подешавања изгледа", - "Player style: ": "Стил плејера: ", + "preferences_player_style_label": "Стил плејера: ", "Dark mode: ": "Тамни режим: ", - "Theme: ": "Тема: ", + "preferences_dark_mode_label": "Тема: ", "dark": "тамна", "light": "светла", - "Thin mode: ": "Узани режим: ", + "preferences_thin_mode_label": "Узани режим: ", "Subscription preferences": "Подешавања о праћењима", - "Show annotations by default for subscribed channels: ": "Увек приказуј анотације за канале које пратим: ", + "preferences_annotations_subscribed_label": "Увек приказуј анотације за канале које пратим: ", "Redirect homepage to feed: ": "Прикажи праћења као почетну страницу: ", - "Number of videos shown in feed: ": "Количина приказаних видеа на доводу: ", - "Sort videos by: ": "Сортирај према: ", + "preferences_max_results_label": "Количина приказаних видеа на доводу: ", + "preferences_sort_label": "Сортирај према: ", "published": "датуму објављивања", "published - reverse": "датуму објављивања - обрнуто", "alphabetically": "алфабету", @@ -94,8 +94,8 @@ "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): ": "Прикажи само обавештења (ако их има): ", + "preferences_unseen_only_label": "Прикажи само негледано: ", + "preferences_notifications_only_label": "Прикажи само обавештења (ако их има): ", "Enable web notifications": "Укључи обавештења преко претраживача", "`x` uploaded a video": "`x`је објавио/ла видео", "`x` is live": "`x` емитује уживо", @@ -108,8 +108,8 @@ "Watch history": "Историја прегледања", "Delete account": "Избришите налог", "Administrator preferences": "Подешавања администратора", - "Default homepage: ": "Подразумевана главна страница: ", - "Feed menu: ": "Мени довода: ", + "preferences_default_home_label": "Подразумевана главна страница: ", + "preferences_feed_menu_label": "Мени довода: ", "CAPTCHA enabled: ": "CAPTCHA укључена?: ", "Login enabled: ": "Пријава укључена?: ", "Registration enabled: ": "Регистрација укључена?: ", diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 1039bc5f..574a07c5 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -61,37 +61,37 @@ "Google verification code": "Google-bekräftelsekod", "Preferences": "Inställningar", "Player preferences": "Spelarinställningar", - "Always loop: ": "Loopa alltid: ", - "Autoplay: ": "Autouppspelning: ", - "Play next by default: ": "Spela nästa som förval: ", - "Autoplay next video: ": "Autouppspela nästa video: ", - "Listen by default: ": "Lyssna som förval: ", - "Proxy videos: ": "Proxy:a videor: ", - "Default speed: ": "Förvald hastighet: ", - "Preferred video quality: ": "Föredragen videokvalitet: ", - "Player volume: ": "Volym: ", - "Default comments: ": "Förvalda kommentarer: ", + "preferences_video_loop_label": "Loopa alltid: ", + "preferences_autoplay_label": "Autouppspelning: ", + "preferences_continue_label": "Spela nästa som förval: ", + "preferences_continue_autoplay_label": "Autouppspela nästa video: ", + "preferences_listen_label": "Lyssna som förval: ", + "preferences_local_label": "Proxy:a videor: ", + "preferences_speed_label": "Förvald hastighet: ", + "preferences_quality_label": "Föredragen videokvalitet: ", + "preferences_volume_label": "Volym: ", + "preferences_comments_label": "Förvalda kommentarer: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Förvalda undertexter: ", + "preferences_captions_label": "Förvalda undertexter: ", "Fallback captions: ": "Ersättningsundertexter: ", - "Show related videos: ": "Visa relaterade videor? ", - "Show annotations by default: ": "Visa länkar-i-videon som förval? ", - "Automatically extend video description: ": "Förläng videobeskrivning automatiskt: ", - "Interactive 360 degree videos: ": "Interaktiva 360-gradervideos: ", + "preferences_related_videos_label": "Visa relaterade videor? ", + "preferences_annotations_label": "Visa länkar-i-videon som förval? ", + "preferences_extend_desc_label": "Förläng videobeskrivning automatiskt: ", + "preferences_vr_mode_label": "Interaktiva 360-gradervideos: ", "Visual preferences": "Visuella inställningar", - "Player style: ": "Spelarstil: ", + "preferences_player_style_label": "Spelarstil: ", "Dark mode: ": "Mörkt läge: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "Mörkt", "light": "Ljust", - "Thin mode: ": "Lättviktigt läge: ", + "preferences_thin_mode_label": "Lättviktigt läge: ", "Miscellaneous preferences": "Övriga inställningar", "Subscription preferences": "Prenumerationsinställningar", - "Show annotations by default for subscribed channels: ": "Visa länkar-i-videor som förval för kanaler som prenumereras på? ", + "preferences_annotations_subscribed_label": "Visa länkar-i-videor som förval för kanaler som prenumereras på? ", "Redirect homepage to feed: ": "Omdirigera hemsida till flöde: ", - "Number of videos shown in feed: ": "Antal videor att visa i flödet: ", - "Sort videos by: ": "Sortera videor: ", + "preferences_max_results_label": "Antal videor att visa i flödet: ", + "preferences_sort_label": "Sortera videor: ", "published": "publicering", "published - reverse": "publicering - omvänd", "alphabetically": "alfabetiskt", @@ -100,8 +100,8 @@ "channel name - reverse": "kanalnamn - omvänd", "Only show latest video from channel: ": "Visa bara senaste videon från kanal: ", "Only show latest unwatched video from channel: ": "Visa bara senaste osedda videon från kanal: ", - "Only show unwatched: ": "Visa bara osedda: ", - "Only show notifications (if there are any): ": "Visa endast aviseringar (om det finns några): ", + "preferences_unseen_only_label": "Visa bara osedda: ", + "preferences_notifications_only_label": "Visa endast aviseringar (om det finns några): ", "Enable web notifications": "Slå på aviseringar", "`x` uploaded a video": "`x` laddade upp en video", "`x` is live": "`x` sänder live", @@ -114,9 +114,9 @@ "Watch history": "Visningshistorik", "Delete account": "Radera konto", "Administrator preferences": "Administratörsinställningar", - "Default homepage: ": "Förvald hemsida: ", - "Feed menu: ": "Flödesmeny: ", - "Show nickname on top: ": "Visa smeknamn överst: ", + "preferences_default_home_label": "Förvald hemsida: ", + "preferences_feed_menu_label": "Flödesmeny: ", + "preferences_show_nick_label": "Visa smeknamn överst: ", "Top enabled: ": "Topp påslaget? ", "CAPTCHA enabled: ": "CAPTCHA påslaget? ", "Login enabled: ": "Inloggning påslaget? ", @@ -372,7 +372,7 @@ "Top": "Topp", "About": "Om", "Rating: ": "Betyg: ", - "Language: ": "Språk: ", + "preferences_locale_label": "Språk: ", "View as playlist": "Visa som spellista", "Default": "Förvalt", "Music": "Musik", diff --git a/locales/tr.json b/locales/tr.json index fb9a7f10..aed79583 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -61,38 +61,38 @@ "Google verification code": "Google doğrulama kodu", "Preferences": "Tercihler", "Player preferences": "Oynatıcı tercihleri", - "Always loop: ": "Sürekli döngü: ", - "Autoplay: ": "Otomatik oynat: ", - "Play next by default: ": "Öntanımlı olarak sonrakini oynat: ", - "Autoplay next video: ": "Sonraki videoyu otomatik oynat: ", - "Listen by default: ": "Öntanımlı olarak dinle: ", - "Proxy videos: ": "Videoları proxy'le: ", - "Default speed: ": "Öntanımlı hız: ", - "Preferred video quality: ": "Tercih edilen video kalitesi: ", - "Player volume: ": "Oynatıcı ses seviyesi: ", - "Default comments: ": "Öntanımlı yorumlar: ", + "preferences_video_loop_label": "Sürekli döngü: ", + "preferences_autoplay_label": "Otomatik oynat: ", + "preferences_continue_label": "Öntanımlı olarak sonrakini oynat: ", + "preferences_continue_autoplay_label": "Sonraki videoyu otomatik oynat: ", + "preferences_listen_label": "Öntanımlı olarak dinle: ", + "preferences_local_label": "Videoları proxy'le: ", + "preferences_speed_label": "Öntanımlı hız: ", + "preferences_quality_label": "Tercih edilen video kalitesi: ", + "preferences_volume_label": "Oynatıcı ses seviyesi: ", + "preferences_comments_label": "Öntanımlı yorumlar: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Öntanımlı altyazılar: ", + "preferences_captions_label": "Öntanımlı altyazılar: ", "Fallback captions: ": "Yedek altyazılar: ", - "Show related videos: ": "İlgili videoları göster: ", - "Show annotations by default: ": "Öntanımlı olarak ek açıklamaları göster: ", - "Automatically extend video description: ": "Video açıklamasını otomatik olarak genişlet: ", - "Interactive 360 degree videos: ": "Etkileşimli 360 derece videolar: ", + "preferences_related_videos_label": "İlgili videoları göster: ", + "preferences_annotations_label": "Öntanımlı olarak ek açıklamaları göster: ", + "preferences_extend_desc_label": "Video açıklamasını otomatik olarak genişlet: ", + "preferences_vr_mode_label": "Etkileşimli 360 derece videolar: ", "Visual preferences": "Görsel tercihler", - "Player style: ": "Oynatıcı biçimi: ", + "preferences_player_style_label": "Oynatıcı biçimi: ", "Dark mode: ": "Karanlık mod: ", - "Theme: ": "Tema: ", + "preferences_dark_mode_label": "Tema: ", "dark": "karanlık", "light": "aydınlık", - "Thin mode: ": "İnce mod: ", + "preferences_thin_mode_label": "İnce mod: ", "Miscellaneous preferences": "Çeşitli tercihler", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Otomatik örnek yeniden yönlendirmesi (yedek: redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Otomatik örnek yeniden yönlendirmesi (yedek: redirect.invidious.io): ", "Subscription preferences": "Abonelik tercihleri", - "Show annotations by default for subscribed channels: ": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ", + "preferences_annotations_subscribed_label": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ", "Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ", - "Number of videos shown in feed: ": "Akışta gösterilen video sayısı: ", - "Sort videos by: ": "Videoları sıralama kriteri: ", + "preferences_max_results_label": "Akışta gösterilen video sayısı: ", + "preferences_sort_label": "Videoları sıralama kriteri: ", "published": "yayınlandı", "published - reverse": "yayınlandı - ters", "alphabetically": "alfabetik olarak", @@ -101,8 +101,8 @@ "channel name - reverse": "kanal adı - ters", "Only show latest video from channel: ": "Sadece kanaldaki en son videoyu göster: ", "Only show latest unwatched video from channel: ": "Sadece kanaldaki en son izlenmemiş videoyu göster: ", - "Only show unwatched: ": "Sadece izlenmemişleri göster: ", - "Only show notifications (if there are any): ": "Sadece bildirimleri göster (eğer varsa): ", + "preferences_unseen_only_label": "Sadece izlenmemişleri göster: ", + "preferences_notifications_only_label": "Sadece bildirimleri göster (eğer varsa): ", "Enable web notifications": "Ağ bildirimlerini etkinleştir", "`x` uploaded a video": "`x` bir video yükledi", "`x` is live": "`x` canlı yayında", @@ -115,9 +115,9 @@ "Watch history": "İzleme geçmişi", "Delete account": "Hesap silme", "Administrator preferences": "Yönetici tercihleri", - "Default homepage: ": "Öntanımlı ana sayfa: ", - "Feed menu: ": "Akış menüsü: ", - "Show nickname on top: ": "Takma adı üstte göster: ", + "preferences_default_home_label": "Öntanımlı ana sayfa: ", + "preferences_feed_menu_label": "Akış menüsü: ", + "preferences_show_nick_label": "Takma adı üstte göster: ", "Top enabled: ": "Top etkin: ", "CAPTCHA enabled: ": "CAPTCHA etkin: ", "Login enabled: ": "Oturum açma etkin: ", @@ -374,7 +374,7 @@ "Top": "Enler", "About": "Hakkında", "Rating: ": "Değerlendirme: ", - "Language: ": "Dil: ", + "preferences_locale_label": "Dil: ", "View as playlist": "Oynatma listesi olarak görüntüle", "Default": "Öntanımlı", "Music": "Müzik", diff --git a/locales/uk.json b/locales/uk.json index 9f3e62dd..da63c941 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -61,34 +61,34 @@ "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: ": "Джерело коментарів: ", + "preferences_video_loop_label": "Завжди повторювати: ", + "preferences_autoplay_label": "Автовідтворення: ", + "preferences_continue_label": "Завжди вмикати наступне відео: ", + "preferences_continue_autoplay_label": "Автовідтворення наступного відео: ", + "preferences_listen_label": "Режим «тільки звук» як усталений: ", + "preferences_local_label": "Програвати відео через проксі? ", + "preferences_speed_label": "Усталена швидкість відео: ", + "preferences_quality_label": "Пріорітетна якість відео: ", + "preferences_volume_label": "Гучність відео: ", + "preferences_comments_label": "Джерело коментарів: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "Основна мова субтитрів: ", + "preferences_captions_label": "Основна мова субтитрів: ", "Fallback captions: ": "Запасна мова субтитрів: ", - "Show related videos: ": "Показувати схожі відео? ", - "Show annotations by default: ": "Завжди показувати анотації? ", + "preferences_related_videos_label": "Показувати схожі відео? ", + "preferences_annotations_label": "Завжди показувати анотації? ", "Visual preferences": "Налаштування сайту", - "Player style: ": "Стиль програвача: ", + "preferences_player_style_label": "Стиль програвача: ", "Dark mode: ": "Темне оформлення: ", - "Theme: ": "Тема: ", + "preferences_dark_mode_label": "Тема: ", "dark": "темна", "light": "Світла", - "Thin mode: ": "Полегшене оформлення: ", + "preferences_thin_mode_label": "Полегшене оформлення: ", "Subscription preferences": "Налаштування підписок", - "Show annotations by default for subscribed channels: ": "Завжди показувати анотації у відео каналів, на які ви підписані? ", + "preferences_annotations_subscribed_label": "Завжди показувати анотації у відео каналів, на які ви підписані? ", "Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ", - "Number of videos shown in feed: ": "Кількість відео з каналів, на які підписані, у потоці: ", - "Sort videos by: ": "Сортувати відео: ", + "preferences_max_results_label": "Кількість відео з каналів, на які підписані, у потоці: ", + "preferences_sort_label": "Сортувати відео: ", "published": "за датою розміщення", "published - reverse": "за датою розміщення в зворотному порядку", "alphabetically": "за абеткою", @@ -97,8 +97,8 @@ "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): ": "Показувати лише сповіщення, якщо вони є: ", + "preferences_unseen_only_label": "Показувати тільки непереглянуті відео: ", + "preferences_notifications_only_label": "Показувати лише сповіщення, якщо вони є: ", "Enable web notifications": "Ввімкнути сповіщення в браузері", "`x` uploaded a video": "`x` розмістив відео", "`x` is live": "`x` у прямому ефірі", @@ -111,8 +111,8 @@ "Watch history": "Історія переглядів", "Delete account": "Видалити обліківку", "Administrator preferences": "Адміністраторські налаштування", - "Default homepage: ": "Усталена домашня сторінка: ", - "Feed menu: ": "Меню потоку з відео: ", + "preferences_default_home_label": "Усталена домашня сторінка: ", + "preferences_feed_menu_label": "Меню потоку з відео: ", "Top enabled: ": "Увімкнути топ відео? ", "CAPTCHA enabled: ": "Увімкнути капчу? ", "Login enabled: ": "Увімкнути авторизацію? ", @@ -363,7 +363,7 @@ "Top": "Топ", "About": "Про сайт", "Rating: ": "Рейтинг: ", - "Language: ": "Мова: ", + "preferences_locale_label": "Мова: ", "View as playlist": "Дивитися як плейлист", "Default": "Усталено", "Music": "Музика", diff --git a/locales/vi.json b/locales/vi.json index 05c4e7e6..e73966ab 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -56,38 +56,38 @@ "Google verification code": "Mã xác minh của Google", "Preferences": "Sở thích", "Player preferences": "Tùy chọn người chơi", - "Always loop: ": "Luôn lặp lại: ", - "Autoplay: ": "Tự chạy: ", - "Play next by default: ": "Phát tiếp theo theo mặc định: ", - "Autoplay next video: ": "Tự động phát video tiếp theo: ", - "Listen by default: ": "Nghe theo mặc định: ", - "Proxy videos: ": "Video proxy: ", - "Default speed: ": "Tốc độ mặc định: ", - "Preferred video quality: ": "Chất lượng video ưa thích: ", - "Player volume: ": "Khối lượng trình phát: ", - "Default comments: ": "Nhận xét mặc định: ", + "preferences_video_loop_label": "Luôn lặp lại: ", + "preferences_autoplay_label": "Tự chạy: ", + "preferences_continue_label": "Phát tiếp theo theo mặc định: ", + "preferences_continue_autoplay_label": "Tự động phát video tiếp theo: ", + "preferences_listen_label": "Nghe theo mặc định: ", + "preferences_local_label": "Video proxy: ", + "preferences_speed_label": "Tốc độ mặc định: ", + "preferences_quality_label": "Chất lượng video ưa thích: ", + "preferences_volume_label": "Khối lượng trình phát: ", + "preferences_comments_label": "Nhận xét mặc định: ", "youtube": "YouTube", "reddit": "reddit", - "Default captions: ": "Phụ đề mặc định: ", + "preferences_captions_label": "Phụ đề mặc định: ", "Fallback captions: ": "Phụ đề dự phòng: ", - "Show related videos: ": "Hiển thị các video có liên quan: ", - "Show annotations by default: ": "Hiển thị chú thích theo mặc định: ", - "Automatically extend video description: ": "Tự động mở rộng mô tả video: ", - "Interactive 360 degree videos: ": "Video 360 độ tương tác: ", + "preferences_related_videos_label": "Hiển thị các video có liên quan: ", + "preferences_annotations_label": "Hiển thị chú thích theo mặc định: ", + "preferences_extend_desc_label": "Tự động mở rộng mô tả video: ", + "preferences_vr_mode_label": "Video 360 độ tương tác: ", "Visual preferences": "Tùy chọn hình ảnh", - "Player style: ": "Phong cách người chơi: ", + "preferences_player_style_label": "Phong cách người chơi: ", "Dark mode: ": "Chế độ tối: ", - "Theme: ": "Chủ đề: ", + "preferences_dark_mode_label": "Chủ đề: ", "dark": "tối", "light": "ánh sáng", - "Thin mode: ": "Chế độ mỏng: ", + "preferences_thin_mode_label": "Chế độ mỏng: ", "Miscellaneous preferences": "Tùy chọn khác", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "Chuyển hướng phiên bản tự động (dự phòng thành redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "Chuyển hướng phiên bản tự động (dự phòng thành redirect.invidious.io): ", "Subscription preferences": "Tùy chọn đăng ký", - "Show annotations by default for subscribed channels: ": "Hiển thị chú thích theo mặc định cho các kênh đã đăng ký: ", + "preferences_annotations_subscribed_label": "Hiển thị chú thích theo mặc định cho các kênh đã đăng ký: ", "Redirect homepage to feed: ": "Chuyển hướng trang chủ đến nguồn cấp dữ liệu: ", - "Number of videos shown in feed: ": "Số lượng video được hiển thị trong nguồn cấp dữ liệu: ", - "Sort videos by: ": "Sắp xếp video theo: ", + "preferences_max_results_label": "Số lượng video được hiển thị trong nguồn cấp dữ liệu: ", + "preferences_sort_label": "Sắp xếp video theo: ", "published": "được phát hành", "published - reverse": "đã xuất bản - đảo ngược", "alphabetically": "theo thứ tự bảng chữ cái", @@ -96,8 +96,8 @@ "channel name - reverse": "tên kênh - đảo ngược", "Only show latest video from channel: ": "Chỉ hiển thị video mới nhất từ kênh: ", "Only show latest unwatched video from channel: ": "Chỉ hiển thị video chưa xem mới nhất từ kênh: ", - "Only show unwatched: ": "Chỉ hiển thị chưa xem: ", - "Only show notifications (if there are any): ": "Chỉ hiển thị thông báo (nếu có): ", + "preferences_unseen_only_label": "Chỉ hiển thị chưa xem: ", + "preferences_notifications_only_label": "Chỉ hiển thị thông báo (nếu có): ", "Enable web notifications": "Bật thông báo web", "`x` uploaded a video": "` x` đã tải lên một video", "`x` is live": "` x` đang phát trực tiếp", @@ -110,9 +110,9 @@ "Watch history": "Lịch sử xem", "Delete account": "Xóa tài khoản", "Administrator preferences": "Tùy chọn quản trị viên", - "Default homepage: ": "Trang chủ mặc định: ", - "Feed menu: ": "Menu nguồn cấp dữ liệu: ", - "Show nickname on top: ": "Hiển thị biệt hiệu ở trên cùng: ", + "preferences_default_home_label": "Trang chủ mặc định: ", + "preferences_feed_menu_label": "Menu nguồn cấp dữ liệu: ", + "preferences_show_nick_label": "Hiển thị biệt hiệu ở trên cùng: ", "Top enabled: ": "Đã bật hàng đầu: ", "CAPTCHA enabled: ": "Đã bật CAPTCHA: ", "Login enabled: ": "Đã bật đăng nhập: ", @@ -301,7 +301,7 @@ "Top": "Hàng đầu", "About": "Trong khoảng", "Rating: ": "Xếp hạng: ", - "Language: ": "Ngôn ngữ: ", + "preferences_locale_label": "Ngôn ngữ: ", "View as playlist": "Xem dưới dạng danh sách phát", "Default": "Mặc định", "Music": "Âm nhạc", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index b8669bec..a34d219c 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -61,38 +61,38 @@ "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: ": "默认评论源: ", + "preferences_video_loop_label": "始终循环: ", + "preferences_autoplay_label": "自动播放: ", + "preferences_continue_label": "默认自动播放下一个视频: ", + "preferences_continue_autoplay_label": "自动播放下一个视频: ", + "preferences_listen_label": "默认只听声音: ", + "preferences_local_label": "是否代理视频: ", + "preferences_speed_label": "默认速度: ", + "preferences_quality_label": "视频质量偏好: ", + "preferences_volume_label": "播放器音量: ", + "preferences_comments_label": "默认评论源: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "默认字幕语言: ", + "preferences_captions_label": "默认字幕语言: ", "Fallback captions: ": "后备字幕语言: ", - "Show related videos: ": "是否显示相关视频: ", - "Show annotations by default: ": "是否默认显示视频注释: ", - "Automatically extend video description: ": "自动展开视频描述: ", - "Interactive 360 degree videos: ": "互动式 360 度视频: ", + "preferences_related_videos_label": "是否显示相关视频: ", + "preferences_annotations_label": "是否默认显示视频注释: ", + "preferences_extend_desc_label": "自动展开视频描述: ", + "preferences_vr_mode_label": "互动式 360 度视频: ", "Visual preferences": "视觉选项", - "Player style: ": "播放器样式: ", + "preferences_player_style_label": "播放器样式: ", "Dark mode: ": "深色模式: ", - "Theme: ": "主题: ", + "preferences_dark_mode_label": "主题: ", "dark": "暗色", "light": "亮色", - "Thin mode: ": "窄页模式: ", + "preferences_thin_mode_label": "窄页模式: ", "Miscellaneous preferences": "其他选项", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自动实例重定向 (回退到redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "自动实例重定向 (回退到redirect.invidious.io): ", "Subscription preferences": "订阅设置", - "Show annotations by default for subscribed channels: ": "默认情况下显示已订阅频道的注释: ", + "preferences_annotations_subscribed_label": "默认情况下显示已订阅频道的注释: ", "Redirect homepage to feed: ": "跳转主页到 feed: ", - "Number of videos shown in feed: ": "Feed 中显示的视频数量: ", - "Sort videos by: ": "视频排序方式: ", + "preferences_max_results_label": "Feed 中显示的视频数量: ", + "preferences_sort_label": "视频排序方式: ", "published": "发布时间", "published - reverse": "发布时间(反向)", "alphabetically": "字母序", @@ -101,8 +101,8 @@ "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): ": "只显示通知 (如果有的话): ", + "preferences_unseen_only_label": "只显示未看过的视频: ", + "preferences_notifications_only_label": "只显示通知 (如果有的话): ", "Enable web notifications": "启用浏览器通知", "`x` uploaded a video": "`x` 上传了视频", "`x` is live": "`x` 正在直播", @@ -115,9 +115,9 @@ "Watch history": "观看历史", "Delete account": "删除账户", "Administrator preferences": "管理员选项", - "Default homepage: ": "默认主页: ", - "Feed menu: ": "Feed 菜单: ", - "Show nickname on top: ": "在顶部显示昵称: ", + "preferences_default_home_label": "默认主页: ", + "preferences_feed_menu_label": "Feed 菜单: ", + "preferences_show_nick_label": "在顶部显示昵称: ", "Top enabled: ": "是否启用“热门视频”页: ", "CAPTCHA enabled: ": "是否启用验证码: ", "Login enabled: ": "是否启用登录: ", @@ -374,7 +374,7 @@ "Top": "热门视频", "About": "关于", "Rating: ": "评分: ", - "Language: ": "语言: ", + "preferences_locale_label": "语言: ", "View as playlist": "作为播放列表查看", "Default": "默认", "Music": "音乐", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 777ef2f7..2021e19a 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -61,38 +61,38 @@ "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: ": "預設留言: ", + "preferences_video_loop_label": "總是循環播放: ", + "preferences_autoplay_label": "自動播放: ", + "preferences_continue_label": "預設播放下一部: ", + "preferences_continue_autoplay_label": "自動播放下一部影片: ", + "preferences_listen_label": "預設聆聽: ", + "preferences_local_label": "代理影片: ", + "preferences_speed_label": "預設速度: ", + "preferences_quality_label": "偏好的影片畫質: ", + "preferences_volume_label": "播放器音量: ", + "preferences_comments_label": "預設留言: ", "youtube": "YouTube", "reddit": "Reddit", - "Default captions: ": "預設字幕: ", + "preferences_captions_label": "預設字幕: ", "Fallback captions: ": "汰退字幕: ", - "Show related videos: ": "顯示相關的影片: ", - "Show annotations by default: ": "預設顯示註釋: ", - "Automatically extend video description: ": "自動展開影片描述: ", - "Interactive 360 degree videos: ": "互動式 360 度影片: ", + "preferences_related_videos_label": "顯示相關的影片: ", + "preferences_annotations_label": "預設顯示註釋: ", + "preferences_extend_desc_label": "自動展開影片描述: ", + "preferences_vr_mode_label": "互動式 360 度影片: ", "Visual preferences": "視覺偏好設定", - "Player style: ": "播放器樣式: ", + "preferences_player_style_label": "播放器樣式: ", "Dark mode: ": "深色模式: ", - "Theme: ": "佈景主題: ", + "preferences_dark_mode_label": "佈景主題: ", "dark": "深色", "light": "淺色", - "Thin mode: ": "精簡模式: ", + "preferences_thin_mode_label": "精簡模式: ", "Miscellaneous preferences": "其他偏好設定", - "Automaticatic instance redirection (fallback to redirect.invidious.io): ": "自動站台重新導向(汰退至 redirect.invidious.io): ", + "preferences_automatic_instance_redirect_label": "自動站台重新導向(汰退至 redirect.invidious.io): ", "Subscription preferences": "訂閱偏好設定", - "Show annotations by default for subscribed channels: ": "預設為已訂閱的頻道顯示註釋: ", + "preferences_annotations_subscribed_label": "預設為已訂閱的頻道顯示註釋: ", "Redirect homepage to feed: ": "重新導向首頁至 feed: ", - "Number of videos shown in feed: ": "顯示在 feed 中的影片數量: ", - "Sort videos by: ": "以此種方式排序影片: ", + "preferences_max_results_label": "顯示在 feed 中的影片數量: ", + "preferences_sort_label": "以此種方式排序影片: ", "published": "已發佈", "published - reverse": "已發佈 - 反向", "alphabetically": "字母", @@ -101,8 +101,8 @@ "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): ": "僅顯示通知(如果有的話): ", + "preferences_unseen_only_label": "僅顯示未觀看的: ", + "preferences_notifications_only_label": "僅顯示通知(如果有的話): ", "Enable web notifications": "啟用網路通知", "`x` uploaded a video": "`x` 上傳了一部影片", "`x` is live": "`x` 正在直播", @@ -115,9 +115,9 @@ "Watch history": "觀看歷史", "Delete account": "刪除帳號", "Administrator preferences": "管理員偏好設定", - "Default homepage: ": "預設首頁: ", - "Feed menu: ": "Feed 選單: ", - "Show nickname on top: ": "在頂部顯示暱稱: ", + "preferences_default_home_label": "預設首頁: ", + "preferences_feed_menu_label": "Feed 選單: ", + "preferences_show_nick_label": "在頂部顯示暱稱: ", "Top enabled: ": "頂部啟用: ", "CAPTCHA enabled: ": "CAPTCHA 啟用: ", "Login enabled: ": "啟用登入: ", @@ -374,7 +374,7 @@ "Top": "熱門影片", "About": "關於", "Rating: ": "評分: ", - "Language: ": "語言: ", + "preferences_locale_label": "語言: ", "View as playlist": "以播放清單檢視", "Default": "預設值", "Music": "音樂", diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 401c15ea..99bd5d91 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -8,37 +8,37 @@ <%= translate(locale, "Player preferences") %>
    - + checked<% end %>>
    - + checked<% end %>>
    - + checked<% end %>>
    - + checked<% end %>>
    - + checked<% end %> <% if CONFIG.disabled?("local") %>disabled<% end %>>
    - + checked<% end %>>
    - + <% {"dash", "hd720", "medium", "small"}.each do |option| %> <% if !(option == "dash" && CONFIG.disabled?("dash")) %> @@ -59,7 +59,7 @@ <% if !CONFIG.disabled?("dash") %>
    - + <%= preferences.volume %>
    - + <% preferences.comments.each_with_index do |comments, index| %> <% CAPTION_LANGUAGES.each do |option| %> @@ -97,29 +97,29 @@
    - + checked<% end %>>
    - + checked<% end %>>
    - + checked<% end %>>
    - + checked<% end %>>
    <%= translate(locale, "Visual preferences") %>
    - + <% {"invidious", "youtube"}.each do |option| %> @@ -137,7 +137,7 @@
    - + checked<% end %>>
    @@ -157,7 +157,7 @@ <% end %>
    - + <% feed_options.each do |option| %> @@ -177,7 +177,7 @@
    <% if env.get? "user" %>
    - + checked<% end %>>
    <% end %> @@ -185,7 +185,7 @@ <%= translate(locale, "Miscellaneous preferences") %>
    - + checked<% end %>>
    @@ -193,17 +193,17 @@ <%= translate(locale, "Subscription preferences") %>
    - + checked<% end %>>
    - +
    - + checked<% end %>>
    - + checked<% end %>>
    @@ -242,7 +242,7 @@ <%= translate(locale, "Administrator preferences") %>
    - + <% feed_options.each do |option| %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 398e25b6..1863c314 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -291,7 +291,7 @@ we're going to need to do it here in order to allow for translations. <% if !video.related_videos.empty? %>
    style="display:none"<% end %>>
    - + checked<% end %>>

    -- cgit v1.2.3 From 1e0712625ae6151ec70ccb2ae2b8db1ef9f517ed Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 18 Oct 2021 21:23:17 +0200 Subject: Normalize translation key for preferences categories categories normalized: - Miscellanous - Player - Visual - Administrator - Data - Subscription --- locales/ar.json | 12 ++++++------ locales/bn_BD.json | 2 +- locales/cs.json | 10 +++++----- locales/da.json | 10 +++++----- locales/de.json | 12 ++++++------ locales/el.json | 10 +++++----- locales/en-US.json | 12 ++++++------ locales/eo.json | 12 ++++++------ locales/es.json | 12 ++++++------ locales/eu.json | 4 ++-- locales/fa.json | 12 ++++++------ locales/fi.json | 12 ++++++------ locales/fr.json | 12 ++++++------ locales/he.json | 10 +++++----- locales/hr.json | 12 ++++++------ locales/hu-HU.json | 10 +++++----- locales/id.json | 12 ++++++------ locales/is.json | 10 +++++----- locales/it.json | 10 +++++----- locales/ja.json | 12 ++++++------ locales/ko.json | 12 ++++++------ locales/lt.json | 12 ++++++------ locales/nb-NO.json | 12 ++++++------ locales/nl.json | 10 +++++----- locales/pl.json | 12 ++++++------ locales/pt-BR.json | 12 ++++++------ locales/pt-PT.json | 12 ++++++------ locales/pt.json | 12 ++++++------ locales/ro.json | 10 +++++----- locales/ru.json | 12 ++++++------ locales/sk.json | 6 +++--- locales/sr.json | 2 +- locales/sr_Cyrl.json | 10 +++++----- locales/sv-SE.json | 12 ++++++------ locales/tr.json | 12 ++++++------ locales/uk.json | 10 +++++----- locales/vi.json | 12 ++++++------ locales/zh-CN.json | 12 ++++++------ locales/zh-TW.json | 12 ++++++------ src/invidious/views/preferences.ecr | 12 ++++++------ 40 files changed, 212 insertions(+), 212 deletions(-) (limited to 'src') diff --git a/locales/ar.json b/locales/ar.json index ea486d84..df45c060 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -60,7 +60,7 @@ "E-mail": "البريد الإلكتروني", "Google verification code": "رمز تحقق جوجل", "Preferences": "التفضيلات", - "Player preferences": "التفضيلات المُشغِّل", + "preferences_category_player": "التفضيلات المُشغِّل", "preferences_video_loop_label": "كرر المقطع المرئيّ دائما: ", "preferences_autoplay_label": "تشغيل تلقائي: ", "preferences_continue_label": "شغل المقطع التالي تلقائيًا: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "اعرض الملاحظات في الفيديو تلقائيا: ", "preferences_extend_desc_label": "توسيع وصف الفيديو تلقائيا: ", "preferences_vr_mode_label": "مقاطع فيديو تفاعلية ب درجة 360: ", - "Visual preferences": "التفضيلات المرئية", + "preferences_category_visual": "التفضيلات المرئية", "preferences_player_style_label": "شكل مشغل الفيديوهات: ", "Dark mode: ": "الوضع الليلى: ", "preferences_dark_mode_label": "المظهر: ", "dark": "غامق (اسود)", "light": "فاتح (ابيض)", "preferences_thin_mode_label": "الوضع الخفيف: ", - "Miscellaneous preferences": "تفضيلات متنوعة", + "preferences_category_misc": "تفضيلات متنوعة", "preferences_automatic_instance_redirect_label": "إعادة توجيه المثيل التلقائي (إعادة التوجيه إلى redirect.invidious.io): ", - "Subscription preferences": "تفضيلات الإشتراك", + "preferences_category_subscription": "تفضيلات الإشتراك", "preferences_annotations_subscribed_label": "عرض الملاحظات في الفيديوهات تلقائيا في القنوات المشترك بها فقط: ", "Redirect homepage to feed: ": "إعادة التوجية من الصفحة الرئيسية لصفحة المشتركين (لرؤية اخر فيديوهات المشتركين): ", "preferences_max_results_label": "عدد الفيديوهات التى ستظهر فى صفحة المشتركين: ", @@ -106,7 +106,7 @@ "Enable web notifications": "تفعيل إشعارات المتصفح", "`x` uploaded a video": "`x` رفع فيديو", "`x` is live": "`x` فى بث مباشر", - "Data preferences": "إعدادات التفضيلات", + "preferences_category_data": "إعدادات التفضيلات", "Clear watch history": "حذف سجل المشاهدة", "Import/export data": "إضافة\\إستخراج البيانات", "Change password": "غير الرقم السرى", @@ -114,7 +114,7 @@ "Manage tokens": "إدارة الرموز", "Watch history": "سجل المشاهدة", "Delete account": "حذف الحساب", - "Administrator preferences": "إعدادات المدير", + "preferences_category_admin": "إعدادات المدير", "preferences_default_home_label": "الصفحة الرئيسية الافتراضية ", "preferences_feed_menu_label": "قائمة التدفقات: ", "preferences_show_nick_label": "إظهار اللقب في الأعلى: ", diff --git a/locales/bn_BD.json b/locales/bn_BD.json index d59e6393..4c1756e8 100644 --- a/locales/bn_BD.json +++ b/locales/bn_BD.json @@ -60,7 +60,7 @@ "E-mail": "ই-মেইল", "Google verification code": "গুগল যাচাইকরণ কোড", "Preferences": "পছন্দসমূহ", - "Player preferences": "প্লেয়ারের পছন্দসমূহ", + "preferences_category_player": "প্লেয়ারের পছন্দসমূহ", "preferences_video_loop_label": "সর্বদা লুপ: ", "preferences_autoplay_label": "স্বয়ংক্রিয় চালু: ", "preferences_continue_label": "ডিফল্টভাবে পরবর্তী চালাও: ", diff --git a/locales/cs.json b/locales/cs.json index 9921566d..dd3f6ea3 100644 --- a/locales/cs.json +++ b/locales/cs.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Verifikační číslo Google", "Preferences": "Nastavení", - "Player preferences": "Nastavení přehravače", + "preferences_category_player": "Nastavení přehravače", "preferences_video_loop_label": "Vždy opakovat: ", "preferences_autoplay_label": "Automatické přehrávání: ", "preferences_continue_label": "Přehrát další ve výchozím stavu: ", @@ -78,14 +78,14 @@ "preferences_related_videos_label": "Zobrazit podobné videa: ", "preferences_annotations_label": "Zobrazovat poznámky ve výchozím nastavení: ", "preferences_extend_desc_label": "Rozšířit automaticky popis u videa: ", - "Visual preferences": "Nastavení vzhledu", + "preferences_category_visual": "Nastavení vzhledu", "preferences_player_style_label": "Styl přehrávače ", "Dark mode: ": "Tmavý režim ", "preferences_dark_mode_label": "Vzhled: ", "dark": "tmavý", "light": "světlý", "preferences_thin_mode_label": "Kompaktní režim: ", - "Subscription preferences": "Nastavení předplatných", + "preferences_category_subscription": "Nastavení předplatných", "preferences_annotations_subscribed_label": "Ve výchozím nastavení zobrazovat poznámky u odebíraných kanálů: ", "Redirect homepage to feed: ": "Přesměrovávat domovskou stránku na informační kanál: ", "preferences_max_results_label": "Počet videí zobrazovaných v informačním kanále: ", @@ -103,7 +103,7 @@ "Enable web notifications": "Povolit webové upozornění", "`x` uploaded a video": "`x` nahrál(a) video", "`x` is live": "`x` je živě", - "Data preferences": "Nastavení dat", + "preferences_category_data": "Nastavení dat", "Clear watch history": "Smazat historii", "Import/export data": "importovat/exportovat data", "Change password": "Změnit heslo", @@ -111,7 +111,7 @@ "Manage tokens": "Spravovat klíče", "Watch history": "Historie Sledování", "Delete account": "Smazat Účet", - "Administrator preferences": "Administrátorská nastavení", + "preferences_category_admin": "Administrátorská nastavení", "preferences_default_home_label": "Základní domovská stránka: ", "preferences_feed_menu_label": "Menu doporučených: ", "CAPTCHA enabled: ": "CAPTCHA povolen: ", diff --git a/locales/da.json b/locales/da.json index 54513db7..72e282bb 100644 --- a/locales/da.json +++ b/locales/da.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Google-verifikationskode", "Preferences": "Præferencer", - "Player preferences": "Afspillerindstillinger", + "preferences_category_player": "Afspillerindstillinger", "preferences_video_loop_label": "Altid gentag: ", "preferences_autoplay_label": "Auto afspil: ", "preferences_continue_label": "Afspil næste som standard: ", @@ -79,14 +79,14 @@ "preferences_annotations_label": "Vis annotationer som standard: ", "preferences_extend_desc_label": "Automatisk udvid videoens beskrivelse: ", "preferences_vr_mode_label": "Interaktiv 360 graders videoer: ", - "Visual preferences": "Visuelle præferencer", + "preferences_category_visual": "Visuelle præferencer", "preferences_player_style_label": "Afspiller stil: ", "Dark mode: ": "Mørk tilstand: ", "preferences_dark_mode_label": "Tema: ", "dark": "mørk", "light": "lys", "preferences_thin_mode_label": "Tynd tilstand: ", - "Subscription preferences": "Abonnements præferencer", + "preferences_category_subscription": "Abonnements præferencer", "preferences_annotations_subscribed_label": "Vis annotationer som standard for abonnerede kanaler: ", "Redirect homepage to feed: ": "Omdiriger startside til feed: ", "preferences_max_results_label": "Antal videoer vist i feed: ", @@ -104,7 +104,7 @@ "Enable web notifications": "Aktiver webnotifikationer", "`x` uploaded a video": "`x` uploadede en video", "`x` is live": "`x` er live", - "Data preferences": "Data præferencer", + "preferences_category_data": "Data præferencer", "Clear watch history": "Ryd afspilningshistorik", "Import/export data": "Importer/exporter data", "Change password": "Skift adgangskode", @@ -112,7 +112,7 @@ "Manage tokens": "Administrer tokens", "Watch history": "Afspilningshistorik", "Delete account": "Slet konto", - "Administrator preferences": "Administrator præferencer", + "preferences_category_admin": "Administrator præferencer", "preferences_default_home_label": "Standard startside: ", "preferences_feed_menu_label": "Feed menu: ", "Top enabled: ": "Top aktiveret: ", diff --git a/locales/de.json b/locales/de.json index 256ef41a..7ec11fef 100644 --- a/locales/de.json +++ b/locales/de.json @@ -60,7 +60,7 @@ "E-mail": "E-Mail", "Google verification code": "Google-Bestätigungscode", "Preferences": "Einstellungen", - "Player preferences": "Wiedergabeeinstellungen", + "preferences_category_player": "Wiedergabeeinstellungen", "preferences_video_loop_label": "Immer wiederholen: ", "preferences_autoplay_label": "Automatisch abspielen: ", "preferences_continue_label": "Immer automatisch nächstes Video spielen: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Standardmäßig Anmerkungen anzeigen? ", "preferences_extend_desc_label": "Videobeschreibung automatisch erweitern: ", "preferences_vr_mode_label": "Interaktive 360 Grad Videos: ", - "Visual preferences": "Anzeigeeinstellungen", + "preferences_category_visual": "Anzeigeeinstellungen", "preferences_player_style_label": "Abspielgeräterstil: ", "Dark mode: ": "Nachtmodus: ", "preferences_dark_mode_label": "Modus: ", "dark": "Nachtmodus", "light": "heller Modus", "preferences_thin_mode_label": "Schlanker Modus: ", - "Miscellaneous preferences": "Sonstige Einstellungen", + "preferences_category_misc": "Sonstige Einstellungen", "preferences_automatic_instance_redirect_label": "Automatische Instanzweiterleitung (über redirect.invidious.io): ", - "Subscription preferences": "Abonnementeinstellungen", + "preferences_category_subscription": "Abonnementeinstellungen", "preferences_annotations_subscribed_label": "Anmerkungen für abonnierte Kanäle standardmäßig anzeigen? ", "Redirect homepage to feed: ": "Startseite zu Feed umleiten: ", "preferences_max_results_label": "Anzahl von Videos die im Feed angezeigt werden: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Webbenachrichtigungen aktivieren", "`x` uploaded a video": "`x` hat ein Video hochgeladen", "`x` is live": "`x` ist live", - "Data preferences": "Dateneinstellungen", + "preferences_category_data": "Dateneinstellungen", "Clear watch history": "Verlauf löschen", "Import/export data": "Daten importieren/exportieren", "Change password": "Passwort ändern", @@ -114,7 +114,7 @@ "Manage tokens": "Tokens verwalten", "Watch history": "Verlauf", "Delete account": "Account löschen", - "Administrator preferences": "Administrator-Einstellungen", + "preferences_category_admin": "Administrator-Einstellungen", "preferences_default_home_label": "Standard-Startseite: ", "preferences_feed_menu_label": "Feed-Menü: ", "preferences_show_nick_label": "Nutzernamen oben anzeigen: ", diff --git a/locales/el.json b/locales/el.json index f3f13c1d..b9189a75 100644 --- a/locales/el.json +++ b/locales/el.json @@ -60,7 +60,7 @@ "E-mail": "Ηλεκτρονικό ταχυδρομείο", "Google verification code": "Κωδικός επαλήθευσης Google", "Preferences": "Προτιμήσεις", - "Player preferences": "Προτιμήσεις αναπαραγωγής", + "preferences_category_player": "Προτιμήσεις αναπαραγωγής", "preferences_video_loop_label": "Αυτόματη επανάληψη: ", "preferences_autoplay_label": "Αυτόματη αναπαραγωγή: ", "preferences_continue_label": "Αναπαραγωγή επόμενου: ", @@ -77,14 +77,14 @@ "Fallback captions: ": "Εναλλακτικοί υπότιτλοι: ", "preferences_related_videos_label": "Προβολή σχετικών βίντεο; ", "preferences_annotations_label": "Αυτόματη προβολή σημειώσεων: ", - "Visual preferences": "Προτιμήσεις εμφάνισης", + "preferences_category_visual": "Προτιμήσεις εμφάνισης", "preferences_player_style_label": "Τεχνοτροπία της συσκευής αναπαραγωγης: ", "Dark mode: ": "Σκοτεινή λειτουργία: ", "preferences_dark_mode_label": "Θέμα: ", "dark": "σκοτεινό", "light": "φωτεινό", "preferences_thin_mode_label": "Ελαφριά λειτουργία: ", - "Subscription preferences": "Προτιμήσεις συνδρομών", + "preferences_category_subscription": "Προτιμήσεις συνδρομών", "preferences_annotations_subscribed_label": "Προβολή σημειώσεων μόνο για κανάλια στα οποία είστε συνδρομητής; ", "Redirect homepage to feed: ": "Ανακατεύθυνση αρχικής στη ροή συνδρομών: ", "preferences_max_results_label": "Αριθμός βίντεο ανά σελίδα ροής συνδρομών: ", @@ -102,7 +102,7 @@ "Enable web notifications": "Ενεργοποίηση ειδοποιήσεων δικτύου", "`x` uploaded a video": "`x` κοινοποίησε ένα βίντεο", "`x` is live": "`x` κάνει live", - "Data preferences": "Προτιμήσεις δεδομένων", + "preferences_category_data": "Προτιμήσεις δεδομένων", "Clear watch history": "Εκκαθάριση ιστορικού προβολής", "Import/export data": "Εισαγωγή/εξαγωγή δεδομένων", "Change password": "Αλλαγή κωδικού πρόσβασης", @@ -110,7 +110,7 @@ "Manage tokens": "Διαχείριση διασυνδέσεων", "Watch history": "Ιστορικό προβολής", "Delete account": "Διαγραφή λογαριασμού", - "Administrator preferences": "Προτιμήσεις διαχειριστή", + "preferences_category_admin": "Προτιμήσεις διαχειριστή", "preferences_default_home_label": "Προεπιλεγμένη αρχική: ", "preferences_feed_menu_label": "Μενού ροής συνδρομών: ", "Top enabled: ": "Ενεργοποίηση κορυφαίων; ", diff --git a/locales/en-US.json b/locales/en-US.json index 89b5d0ae..fee73217 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Google verification code", "Preferences": "Preferences", - "Player preferences": "Player preferences", + "preferences_category_player": "Player preferences", "preferences_video_loop_label": "Always loop: ", "preferences_autoplay_label": "Autoplay: ", "preferences_continue_label": "Play next by default: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Show annotations by default: ", "preferences_extend_desc_label": "Automatically extend video description: ", "preferences_vr_mode_label": "Interactive 360 degree videos: ", - "Visual preferences": "Visual preferences", + "preferences_category_visual": "Visual preferences", "preferences_player_style_label": "Player style: ", "Dark mode: ": "Dark mode: ", "preferences_dark_mode_label": "Theme: ", "dark": "dark", "light": "light", "preferences_thin_mode_label": "Thin mode: ", - "Miscellaneous preferences": "Miscellaneous preferences", + "preferences_category_misc": "Miscellaneous preferences", "preferences_automatic_instance_redirect_label": "Automaticatic instance redirection (fallback to redirect.invidious.io): ", - "Subscription preferences": "Subscription preferences", + "preferences_category_subscription": "Subscription preferences", "preferences_annotations_subscribed_label": "Show annotations by default for subscribed channels? ", "Redirect homepage to feed: ": "Redirect homepage to feed: ", "preferences_max_results_label": "Number of videos shown in feed: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Enable web notifications", "`x` uploaded a video": "`x` uploaded a video", "`x` is live": "`x` is live", - "Data preferences": "Data preferences", + "preferences_category_data": "Data preferences", "Clear watch history": "Clear watch history", "Import/export data": "Import/export data", "Change password": "Change password", @@ -114,7 +114,7 @@ "Manage tokens": "Manage tokens", "Watch history": "Watch history", "Delete account": "Delete account", - "Administrator preferences": "Administrator preferences", + "preferences_category_admin": "Administrator preferences", "preferences_default_home_label": "Default homepage: ", "preferences_feed_menu_label": "Feed menu: ", "preferences_show_nick_label": "Show nickname on top: ", diff --git a/locales/eo.json b/locales/eo.json index e6f0b3be..c9c16a74 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -60,7 +60,7 @@ "E-mail": "Retpoŝto", "Google verification code": "Kontrolkodo de Google", "Preferences": "Agordoj", - "Player preferences": "Spektilaj agordoj", + "preferences_category_player": "Spektilaj agordoj", "preferences_video_loop_label": "Ĉiam ripeti: ", "preferences_autoplay_label": "Aŭtomate ludi: ", "preferences_continue_label": "Ludi sekvan defaŭlte: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Ĉu montri prinotojn defaŭlte? ", "preferences_extend_desc_label": "Aŭtomate etendi priskribon de filmeto: ", "preferences_vr_mode_label": "Interagaj 360-gradaj filmetoj: ", - "Visual preferences": "Vidaj preferoj", + "preferences_category_visual": "Vidaj preferoj", "preferences_player_style_label": "Ludila stilo: ", "Dark mode: ": "Malhela reĝimo: ", "preferences_dark_mode_label": "Etoso: ", "dark": "malhela", "light": "hela", "preferences_thin_mode_label": "Maldika reĝimo: ", - "Miscellaneous preferences": "Aliaj agordoj", + "preferences_category_misc": "Aliaj agordoj", "preferences_automatic_instance_redirect_label": "Aŭtomata alidirektado de instalaĵo (retropaŝo al redirect.invidious.io): ", - "Subscription preferences": "Abonaj agordoj", + "preferences_category_subscription": "Abonaj agordoj", "preferences_annotations_subscribed_label": "Ĉu montri prinotojn defaŭlte por abonitaj kanaloj? ", "Redirect homepage to feed: ": "Alidirekti hejmpâgon al fluo: ", "preferences_max_results_label": "Nombro da filmetoj montritaj en fluo: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Ebligi retejajn sciigojn", "`x` uploaded a video": "`x` alŝutis filmeton", "`x` is live": "`x` estas nuna", - "Data preferences": "Datumagordoj", + "preferences_category_data": "Datumagordoj", "Clear watch history": "Forigi vidohistorion", "Import/export data": "Importi/Eksporti datumojn", "Change password": "Ŝanĝi pasvorton", @@ -114,7 +114,7 @@ "Manage tokens": "Administri ĵetonojn", "Watch history": "Vidohistorio", "Delete account": "Forigi konton", - "Administrator preferences": "Agordoj de administranto", + "preferences_category_admin": "Agordoj de administranto", "preferences_default_home_label": "Defaŭlta hejmpaĝo: ", "preferences_feed_menu_label": "Flua menuo: ", "preferences_show_nick_label": "Montri kromnomon supre: ", diff --git a/locales/es.json b/locales/es.json index fb8f2baf..49d1e09e 100644 --- a/locales/es.json +++ b/locales/es.json @@ -60,7 +60,7 @@ "E-mail": "Correo", "Google verification code": "Código de verificación de Google", "Preferences": "Preferencias", - "Player preferences": "Preferencias del reproductor", + "preferences_category_player": "Preferencias del reproductor", "preferences_video_loop_label": "Repetir siempre: ", "preferences_autoplay_label": "Reproducción automática: ", "preferences_continue_label": "Reproducir siguiente por defecto: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "¿Mostrar anotaciones por defecto? ", "preferences_extend_desc_label": "Extender automáticamente la descripción del vídeo: ", "preferences_vr_mode_label": "Vídeos interactivos de 360 grados: ", - "Visual preferences": "Preferencias visuales", + "preferences_category_visual": "Preferencias visuales", "preferences_player_style_label": "Estilo de reproductor: ", "Dark mode: ": "Modo oscuro: ", "preferences_dark_mode_label": "Tema: ", "dark": "oscuro", "light": "claro", "preferences_thin_mode_label": "Modo compacto: ", - "Miscellaneous preferences": "Preferencias misceláneas", + "preferences_category_misc": "Preferencias misceláneas", "preferences_automatic_instance_redirect_label": "Redirección automática de instancia (segunda opción a redirect.invidious.io): ", - "Subscription preferences": "Preferencias de la suscripción", + "preferences_category_subscription": "Preferencias de la suscripción", "preferences_annotations_subscribed_label": "¿Mostrar anotaciones por defecto para los canales suscritos? ", "Redirect homepage to feed: ": "Redirigir la página de inicio a la fuente: ", "preferences_max_results_label": "Número de vídeos mostrados en la fuente: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Habilitar notificaciones web", "`x` uploaded a video": "`x` subió un video", "`x` is live": "`x` esta en vivo", - "Data preferences": "Preferencias de los datos", + "preferences_category_data": "Preferencias de los datos", "Clear watch history": "Borrar el historial de reproducción", "Import/export data": "Importar/Exportar datos", "Change password": "Cambiar contraseña", @@ -114,7 +114,7 @@ "Manage tokens": "Gestionar tokens", "Watch history": "Historial de reproducción", "Delete account": "Borrar cuenta", - "Administrator preferences": "Preferencias de administrador", + "preferences_category_admin": "Preferencias de administrador", "preferences_default_home_label": "Página de inicio por defecto: ", "preferences_feed_menu_label": "Menú de fuentes: ", "preferences_show_nick_label": "Mostrar nombre de usuario arriba: ", diff --git a/locales/eu.json b/locales/eu.json index 1f0f528a..1458e9a9 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -57,7 +57,7 @@ "Register": "Eman izena", "E-mail": "E-posta", "Preferences": "Hobespenak", - "Player preferences": "Erreproduzigailuaren hobespenak", + "preferences_category_player": "Erreproduzigailuaren hobespenak", "preferences_autoplay_label": "Automatikoki erreproduzitu: ", "preferences_continue_autoplay_label": "Erreproduzitu automatikoki hurrengo bideoa: ", "preferences_quality_label": "Hobetsitako bideoaren kalitatea: ", @@ -68,7 +68,7 @@ "preferences_captions_label": "Lehenetsitako azpitituluak: ", "preferences_related_videos_label": "Erakutsi erlazionatutako bideoak: ", "preferences_annotations_label": "Erakutsi oharrak modu lehenetsian: ", - "Visual preferences": "Hobespen bisualak", + "preferences_category_visual": "Hobespen bisualak", "preferences_player_style_label": "Erreproduzigailu mota: ", "Dark mode: ": "Gai iluna: ", "preferences_dark_mode_label": "Gaia: ", diff --git a/locales/fa.json b/locales/fa.json index 304b0764..efee1cdb 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -60,7 +60,7 @@ "E-mail": "ایمیل", "Google verification code": "کد تایید گوگل", "Preferences": "ترجیحات", - "Player preferences": "ترجیحات نمایش‌دهنده", + "preferences_category_player": "ترجیحات نمایش‌دهنده", "preferences_video_loop_label": "همیشه تکرار شنوده: ", "preferences_autoplay_label": "نمایش خودکار: ", "preferences_continue_label": "پخش بعدی به طور پیشفرض: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "نمایش حاشیه نویسی ها به طور پیشفرض: ", "preferences_extend_desc_label": "گسترش خودکار توضیحات ویدئو: ", "preferences_vr_mode_label": "ویدئوها ۳۶۰ درجه تعاملی: ", - "Visual preferences": "ترجیحات بصری", + "preferences_category_visual": "ترجیحات بصری", "preferences_player_style_label": "حالت پخش کننده: ", "Dark mode: ": "حالت تاریک: ", "preferences_dark_mode_label": "تم: ", "dark": "تاریک", "light": "روشن", "preferences_thin_mode_label": "حالت نازک: ", - "Miscellaneous preferences": "ترجیحات متفرقه", + "preferences_category_misc": "ترجیحات متفرقه", "preferences_automatic_instance_redirect_label": "هدایت خودکار نمونه (به طور پیش‌فرض به redirect.invidious.io): ", - "Subscription preferences": "ترجیحات اشتراک", + "preferences_category_subscription": "ترجیحات اشتراک", "preferences_annotations_subscribed_label": "نمایش حاشیه نویسی ها به طور پیشفرض برای کانال های مشترک شده: ", "Redirect homepage to feed: ": "تغییر مسیر صفحه خانه به خوراک: ", "preferences_max_results_label": "تعداد ویدیو های نمایش داده شده در خوراک: ", @@ -106,7 +106,7 @@ "Enable web notifications": "فعال کردن اعلان های وب", "`x` uploaded a video": "`x` یک ویدیو بارگذاری کرد", "`x` is live": "`x` زنده است", - "Data preferences": "ترجیحات داده", + "preferences_category_data": "ترجیحات داده", "Clear watch history": "پاک‌کردن تاریخچه تماشا", "Import/export data": "وارد کردن/خارج کردن داده", "Change password": "تغییر گذرواژه", @@ -114,7 +114,7 @@ "Manage tokens": "مدیریت توکن ها", "Watch history": "تاریخچه تماشا", "Delete account": "حذف حساب کاربری", - "Administrator preferences": "ترجیحات مدیریت", + "preferences_category_admin": "ترجیحات مدیریت", "preferences_default_home_label": "صفحه خانه پیشفرض ", "preferences_feed_menu_label": "منو خوراک: ", "preferences_show_nick_label": "نمایش نام مستعار در بالا: ", diff --git a/locales/fi.json b/locales/fi.json index 3669fa6b..d5a07385 100644 --- a/locales/fi.json +++ b/locales/fi.json @@ -60,7 +60,7 @@ "E-mail": "Sähköposti", "Google verification code": "Google-vahvistuskoodi", "Preferences": "Asetukset", - "Player preferences": "Soittimen asetukset", + "preferences_category_player": "Soittimen asetukset", "preferences_video_loop_label": "Aina silmukka: ", "preferences_autoplay_label": "Automaattinen toisto: ", "preferences_continue_label": "Toista seuraava oletuksena: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Näytä huomautukset oletuksena: ", "preferences_extend_desc_label": "Laajenna automaattisesti videon kuvausta: ", "preferences_vr_mode_label": "Interaktiiviset 360-asteiset videot: ", - "Visual preferences": "Visuaaliset asetukset", + "preferences_category_visual": "Visuaaliset asetukset", "preferences_player_style_label": "Soittimen tyyli: ", "Dark mode: ": "Tumma tila: ", "preferences_dark_mode_label": "Teema: ", "dark": "tumma", "light": "vaalea", "preferences_thin_mode_label": "Kapea tila ", - "Miscellaneous preferences": "Sekalaiset asetukset", + "preferences_category_misc": "Sekalaiset asetukset", "preferences_automatic_instance_redirect_label": "Automaattinen palveluntarjoajan uudelleenohjaus (perääntyminen sivulle redirect.invidious.io) ", - "Subscription preferences": "Tilausten asetukset", + "preferences_category_subscription": "Tilausten asetukset", "preferences_annotations_subscribed_label": "Näytä oletuksena tilattujen kanavien huomautukset: ", "Redirect homepage to feed: ": "Uudelleenohjaa kotisivu syötteeseen: ", "preferences_max_results_label": "Syötteessä näytettävien videoiden määrä: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Näytä verkkoilmoitukset", "`x` uploaded a video": "`x` latasi videon", "`x` is live": "`x` lähettää suorana", - "Data preferences": "Tietojen asetukset", + "preferences_category_data": "Tietojen asetukset", "Clear watch history": "Tyhjennä katseluhistoria", "Import/export data": "Tuo/vie tiedot", "Change password": "Vaihda salasana", @@ -114,7 +114,7 @@ "Manage tokens": "Hallinnoi tunnuksia", "Watch history": "Katseluhistoria", "Delete account": "Poista tili", - "Administrator preferences": "Järjestelmänvalvojan asetukset", + "preferences_category_admin": "Järjestelmänvalvojan asetukset", "preferences_default_home_label": "Oletuskotisivu: ", "preferences_feed_menu_label": "Syötevalikko: ", "preferences_show_nick_label": "Näytä nimimerkki ylimpänä: ", diff --git a/locales/fr.json b/locales/fr.json index c9cea197..2332594a 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Code de vérification Google", "Preferences": "Préférences", - "Player preferences": "Préférences du lecteur", + "preferences_category_player": "Préférences du lecteur", "preferences_video_loop_label": "Lire en boucle : ", "preferences_autoplay_label": "Lancer la lecture automatiquement : ", "preferences_continue_label": "Lire les vidéos suivantes par défaut : ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Afficher les annotations par défaut : ", "preferences_extend_desc_label": "Etendre automatiquement la description : ", "preferences_vr_mode_label": "Vidéos interactives à 360° : ", - "Visual preferences": "Préférences du site", + "preferences_category_visual": "Préférences du site", "preferences_player_style_label": "Style du lecteur : ", "Dark mode: ": "Mode sombre : ", "preferences_dark_mode_label": "Thème : ", "dark": "sombre", "light": "clair", "preferences_thin_mode_label": "Mode léger : ", - "Miscellaneous preferences": "Paramètres divers", + "preferences_category_misc": "Paramètres divers", "preferences_automatic_instance_redirect_label": "Redirection vers une autre instance automatique (via redirect.invidious.io) : ", - "Subscription preferences": "Préférences des abonnements", + "preferences_category_subscription": "Préférences des abonnements", "preferences_annotations_subscribed_label": "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 : ", "preferences_max_results_label": "Nombre de vidéos affichées dans la page d'abonnements : ", @@ -106,7 +106,7 @@ "Enable web notifications": "Activer les notifications web", "`x` uploaded a video": "`x` a partagé une vidéo", "`x` is live": "`x` est en direct", - "Data preferences": "Préférences liées aux données", + "preferences_category_data": "Préférences liées aux données", "Clear watch history": "Supprimer l'historique des vidéos regardées", "Import/export data": "Importer/exporter les données", "Change password": "Modifier le mot de passe", @@ -114,7 +114,7 @@ "Manage tokens": "Gérer les tokens", "Watch history": "Historique de visionnage", "Delete account": "Supprimer votre compte", - "Administrator preferences": "Préferences d'Administration", + "preferences_category_admin": "Préferences d'Administration", "preferences_default_home_label": "Page d'accueil par défaut : ", "preferences_feed_menu_label": "Préferences des abonnements : ", "preferences_show_nick_label": "Afficher le nom d'utilisateur en haut à droite : ", diff --git a/locales/he.json b/locales/he.json index 7383c4ba..dce0ef2f 100644 --- a/locales/he.json +++ b/locales/he.json @@ -60,7 +60,7 @@ "E-mail": "דוא״ל", "Google verification code": "קוד האימות של Google", "Preferences": "העדפות", - "Player preferences": "העדפות הנגן", + "preferences_category_player": "העדפות הנגן", "preferences_autoplay_label": "ניגון אוטומטי: ", "preferences_continue_label": "ניגון הסרטון הבא כברירת מחדל: ", "preferences_continue_autoplay_label": "ניגון הסרטון הבא באופן אוטומטי: ", @@ -75,13 +75,13 @@ "Fallback captions: ": "כתוביות גיבוי ", "preferences_related_videos_label": "הצגת סרטונים קשורים: ", "preferences_annotations_label": "הצגת הערות כברירת מחדל: ", - "Visual preferences": "העדפות חזותיות", + "preferences_category_visual": "העדפות חזותיות", "preferences_player_style_label": "סגנון הנגן: ", "Dark mode: ": "מצב כהה: ", "preferences_dark_mode_label": "ערכת נושא: ", "dark": "כהה", "light": "בהיר", - "Subscription preferences": "העדפות מינויים", + "preferences_category_subscription": "העדפות מינויים", "preferences_annotations_subscribed_label": "Show annotations by default for subscribed channels? ", "preferences_max_results_label": "מספר הסרטונים שמוצגים בהזנה: ", "preferences_sort_label": "מיון הסרטונים לפי: ", @@ -96,7 +96,7 @@ "preferences_notifications_only_label": "הצגת התראות בלבד (אם ישנן): ", "`x` uploaded a video": "סרטון הועלה על ידי `x`", "`x` is live": "`x` בשידור חי", - "Data preferences": "העדפות נתונים", + "preferences_category_data": "העדפות נתונים", "Clear watch history": "ניקוי היסטוריית הצפייה", "Import/export data": "ייבוא/ייצוא נתונים", "Change password": "שינוי הסיסמה", @@ -104,7 +104,7 @@ "Manage tokens": "ניהול אסימונים", "Watch history": "היסטוריית צפייה", "Delete account": "מחיקת החשבון", - "Administrator preferences": "הגדרות ניהול מערכת", + "preferences_category_admin": "הגדרות ניהול מערכת", "preferences_default_home_label": "Default homepage: ", "preferences_feed_menu_label": "תפריט ההזנה: ", "Save preferences": "שמירת ההעדפות", diff --git a/locales/hr.json b/locales/hr.json index 09041c6d..884bf021 100644 --- a/locales/hr.json +++ b/locales/hr.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Googleov potvrdni kod", "Preferences": "Postavke", - "Player preferences": "Postavke playera", + "preferences_category_player": "Postavke playera", "preferences_video_loop_label": "Uvijek ponavljaj: ", "preferences_autoplay_label": "Automatski reproduciraj: ", "preferences_continue_label": "Standardno reproduciraj sljedeći: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Standardno prikaži napomene: ", "preferences_extend_desc_label": "Automatski proširi opis videa: ", "preferences_vr_mode_label": "Interaktivna videa od 360 stupnjeva: ", - "Visual preferences": "Postavke prikaza", + "preferences_category_visual": "Postavke prikaza", "preferences_player_style_label": "Stil playera: ", "Dark mode: ": "Tamni modus: ", "preferences_dark_mode_label": "Tema: ", "dark": "tamno", "light": "svijetlo", "preferences_thin_mode_label": "Pojednostavljen prikaz: ", - "Miscellaneous preferences": "Razne postavke", + "preferences_category_misc": "Razne postavke", "preferences_automatic_instance_redirect_label": "Automatsko preusmjeravanje instance (u krajnjem slučaju koristi redirect.invidious.io): ", - "Subscription preferences": "Postavke pretplata", + "preferences_category_subscription": "Postavke pretplata", "preferences_annotations_subscribed_label": "Standardno prikaži napomene za pretplaćene kanale: ", "Redirect homepage to feed: ": "Preusmjeri početnu stranicu na feed: ", "preferences_max_results_label": "Broj prikazanih videa u feedu: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Aktiviraj web-obavijesti", "`x` uploaded a video": "`x` je poslao/la video", "`x` is live": "`x` je uživo", - "Data preferences": "Postavke podataka", + "preferences_category_data": "Postavke podataka", "Clear watch history": "Izbriši povijest gledanja", "Import/export data": "Uvezi/izvezi podatke", "Change password": "Promijeni lozinku", @@ -114,7 +114,7 @@ "Manage tokens": "Upravljaj tokenima", "Watch history": "Povijest gledanja", "Delete account": "Izbriši račun", - "Administrator preferences": "Postavke administratora", + "preferences_category_admin": "Postavke administratora", "preferences_default_home_label": "Standardna početna stranica: ", "preferences_feed_menu_label": "Izbornik za feedove: ", "preferences_show_nick_label": "Prikaži nadimak na vrhu: ", diff --git a/locales/hu-HU.json b/locales/hu-HU.json index 4f68a1d5..2e721a0d 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -57,7 +57,7 @@ "E-mail": "E-mail", "Google verification code": "Google verifikációs kód", "Preferences": "Beállítások", - "Player preferences": "Lejátszó beállítások", + "preferences_category_player": "Lejátszó beállítások", "preferences_video_loop_label": "Mindig loop-ol: ", "preferences_autoplay_label": "Automatikus lejátszás: ", "preferences_continue_label": "Következő lejátszása alapértelmezésben: ", @@ -76,14 +76,14 @@ "preferences_annotations_label": "Szövegmagyarázatok mutatása alapértelmezésben: ", "preferences_extend_desc_label": "Automatikusan növelje meg a videó leírását", "preferences_vr_mode_label": "Interaktív 360° videók", - "Visual preferences": "Kinézeti beállítások", + "preferences_category_visual": "Kinézeti beállítások", "preferences_player_style_label": "Lejátszó stílusa: ", "Dark mode: ": "Sötét mód: ", "preferences_dark_mode_label": "Téma: ", "dark": "sötét", "light": "világos", "preferences_thin_mode_label": "Vékony mód: ", - "Subscription preferences": "Feliratkozási beállítások", + "preferences_category_subscription": "Feliratkozási beállítások", "preferences_annotations_subscribed_label": "Szövegmagyarázatok mutatása alapértelmezésben feliratkozott csatornák esetében: ", "Redirect homepage to feed: ": "Kezdő oldal átirányitása a feed-re: ", "preferences_max_results_label": "Feed-ben mutatott videók száma: ", @@ -101,7 +101,7 @@ "Enable web notifications": "Web értesítések bekapcsolása", "`x` uploaded a video": "`x` feltöltött egy videót", "`x` is live": "`x` élő", - "Data preferences": "Adat beállítások", + "preferences_category_data": "Adat beállítások", "Clear watch history": "Megtekintési napló törlése", "Import/export data": "Adat Import/Export", "Change password": "Jelszócsere", @@ -109,7 +109,7 @@ "Manage tokens": "Tokenek kezelése", "Watch history": "Megtekintési napló", "Delete account": "Fiók törlése", - "Administrator preferences": "Adminisztrátor beállítások", + "preferences_category_admin": "Adminisztrátor beállítások", "preferences_default_home_label": "Alapértelmezett oldal: ", "preferences_feed_menu_label": "Feed menü: ", "Top enabled: ": "Top lista engedélyezve: ", diff --git a/locales/id.json b/locales/id.json index 015d9b10..2623efe4 100644 --- a/locales/id.json +++ b/locales/id.json @@ -60,7 +60,7 @@ "E-mail": "Surel", "Google verification code": "Kode verifikasi Google", "Preferences": "Preferensi", - "Player preferences": "Preferensi pemutar", + "preferences_category_player": "Preferensi pemutar", "preferences_video_loop_label": "Selalu ulangi: ", "preferences_autoplay_label": "Putar-Otomatis: ", "preferences_continue_label": "Putar selanjutnya secara default: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Tampilkan anotasi secara default: ", "preferences_extend_desc_label": "Perluas deskripsi video secara otomatis: ", "preferences_vr_mode_label": "Video interaktif 360°: ", - "Visual preferences": "Preferensi visual", + "preferences_category_visual": "Preferensi visual", "preferences_player_style_label": "Gaya pemutar: ", "Dark mode: ": "Mode gelap: ", "preferences_dark_mode_label": "Tema: ", "dark": "gelap", "light": "terang", "preferences_thin_mode_label": "Mode tipis: ", - "Miscellaneous preferences": "Preferensi lainnya", + "preferences_category_misc": "Preferensi lainnya", "preferences_automatic_instance_redirect_label": "Pengalihan instans otomatis (fallback ke redirect.invidious.io): ", - "Subscription preferences": "Preferensi langganan", + "preferences_category_subscription": "Preferensi langganan", "preferences_annotations_subscribed_label": "Tampilkan anotasi secara default untuk kanal langganan: ", "Redirect homepage to feed: ": "Arahkan kembali laman beranda ke umpan: ", "preferences_max_results_label": "Jumlah video ditampilkan di umpan: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Aktifkan pemberitahuan web", "`x` uploaded a video": "`x` mengunggah video", "`x` is live": "`x` sedang siaran langsung", - "Data preferences": "Preferensi Data", + "preferences_category_data": "Preferensi Data", "Clear watch history": "Bersihkan riwayat tontonan", "Import/export data": "Impor/Ekspor data", "Change password": "Ganti kata sandi", @@ -114,7 +114,7 @@ "Manage tokens": "Atur token", "Watch history": "Riwayat tontonan", "Delete account": "Hapus akun", - "Administrator preferences": "Preferensi administrator", + "preferences_category_admin": "Preferensi administrator", "preferences_default_home_label": "Laman beranda default: ", "preferences_feed_menu_label": "Menu umpan: ", "preferences_show_nick_label": "Tampilkan nama panggilan di atas: ", diff --git a/locales/is.json b/locales/is.json index fecc4116..77ab6287 100644 --- a/locales/is.json +++ b/locales/is.json @@ -60,7 +60,7 @@ "E-mail": "Tölvupóstur", "Google verification code": "Google staðfestingarkóði", "Preferences": "Kjörstillingar", - "Player preferences": "Kjörstillingar spilara", + "preferences_category_player": "Kjörstillingar spilara", "preferences_video_loop_label": "Alltaf lykkja: ", "preferences_autoplay_label": "Spila sjálfkrafa: ", "preferences_continue_label": "Spila næst sjálfgefið: ", @@ -77,14 +77,14 @@ "Fallback captions: ": "Varatextar: ", "preferences_related_videos_label": "Sýna tengd myndbönd? ", "preferences_annotations_label": "Á að sýna glósur sjálfgefið? ", - "Visual preferences": "Sjónrænar stillingar", + "preferences_category_visual": "Sjónrænar stillingar", "preferences_player_style_label": "Spilara stíl: ", "Dark mode: ": "Myrkur ham: ", "preferences_dark_mode_label": "Þema: ", "dark": "dimmt", "light": "ljóst", "preferences_thin_mode_label": "Þunnt ham: ", - "Subscription preferences": "Áskriftarstillingar", + "preferences_category_subscription": "Áskriftarstillingar", "preferences_annotations_subscribed_label": "Á að sýna glósur sjálfgefið fyrir áskriftarrásir? ", "Redirect homepage to feed: ": "Endurbeina heimasíðu að straumi: ", "preferences_max_results_label": "Fjöldi myndbanda sem sýndir eru í straumi: ", @@ -102,7 +102,7 @@ "Enable web notifications": "Virkja veftilkynningar", "`x` uploaded a video": "`x` hlóð upp myndband", "`x` is live": "`x` er í beinni", - "Data preferences": "Gagnastillingar", + "preferences_category_data": "Gagnastillingar", "Clear watch history": "Hreinsa áhorfssögu", "Import/export data": "Flytja inn/út gögn", "Change password": "Breyta lykilorði", @@ -110,7 +110,7 @@ "Manage tokens": "Stjórna tákn", "Watch history": "Áhorfssögu", "Delete account": "Eyða reikningi", - "Administrator preferences": "Kjörstillingar stjórnanda", + "preferences_category_admin": "Kjörstillingar stjórnanda", "preferences_default_home_label": "Sjálfgefin heimasíða: ", "preferences_feed_menu_label": "Straum valmynd: ", "Top enabled: ": "Toppur virkur? ", diff --git a/locales/it.json b/locales/it.json index e3ebba89..b90a1e93 100644 --- a/locales/it.json +++ b/locales/it.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Codice di verifica Google", "Preferences": "Preferenze", - "Player preferences": "Preferenze del riproduttore", + "preferences_category_player": "Preferenze del riproduttore", "preferences_video_loop_label": "Ripeti sempre: ", "preferences_autoplay_label": "Riproduzione automatica: ", "preferences_continue_label": "Riproduzione successiva predefinita: ", @@ -77,14 +77,14 @@ "Fallback captions: ": "Sottotitoli alternativi: ", "preferences_related_videos_label": "Mostra video correlati: ", "preferences_annotations_label": "Mostra le annotazioni in modo predefinito: ", - "Visual preferences": "Preferenze grafiche", + "preferences_category_visual": "Preferenze grafiche", "preferences_player_style_label": "Stile riproduttore: ", "Dark mode: ": "Tema scuro: ", "preferences_dark_mode_label": "Tema: ", "dark": "scuro", "light": "chiaro", "preferences_thin_mode_label": "Modalità per connessioni lente: ", - "Subscription preferences": "Preferenze iscrizioni", + "preferences_category_subscription": "Preferenze iscrizioni", "preferences_annotations_subscribed_label": "Mostrare annotazioni in modo predefinito per i canali sottoscritti: ", "Redirect homepage to feed: ": "Reindirizza la pagina principale a quella delle iscrizioni: ", "preferences_max_results_label": "Numero di video da mostrare nelle iscrizioni: ", @@ -102,7 +102,7 @@ "Enable web notifications": "Attiva le notifiche web", "`x` uploaded a video": "`x` ha caricato un video", "`x` is live": "`x` è in diretta", - "Data preferences": "Preferenze dati", + "preferences_category_data": "Preferenze dati", "Clear watch history": "Cancella la cronologia dei video guardati", "Import/export data": "Importazione/esportazione dati", "Change password": "Modifica password", @@ -110,7 +110,7 @@ "Manage tokens": "Gestisci i gettoni", "Watch history": "Cronologia dei video", "Delete account": "Elimina l'account", - "Administrator preferences": "Preferenze amministratore", + "preferences_category_admin": "Preferenze amministratore", "preferences_default_home_label": "Pagina principale predefinita: ", "preferences_feed_menu_label": "Menu iscrizioni: ", "Top enabled: ": "Top abilitato: ", diff --git a/locales/ja.json b/locales/ja.json index 4c3dd9cf..f67150fe 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -60,7 +60,7 @@ "E-mail": "メールアドレス", "Google verification code": "Google 認証コード", "Preferences": "設定", - "Player preferences": "プレイヤー設定", + "preferences_category_player": "プレイヤー設定", "preferences_video_loop_label": "常にループ: ", "preferences_autoplay_label": "自動再生: ", "preferences_continue_label": "デフォルトで次を再生: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "デフォルトでアノテーションを表示: ", "preferences_extend_desc_label": "動画の説明文を自動的に拡張: ", "preferences_vr_mode_label": "対話的な360°動画: ", - "Visual preferences": "外観設定", + "preferences_category_visual": "外観設定", "preferences_player_style_label": "プレイヤースタイル: ", "Dark mode: ": "ダークモード: ", "preferences_dark_mode_label": "テーマ: ", "dark": "ダーク", "light": "ライト", "preferences_thin_mode_label": "最小モード: ", - "Miscellaneous preferences": "雑設定", + "preferences_category_misc": "雑設定", "preferences_automatic_instance_redirect_label": "自動インスタンスの移転(redirect.invidious.ioにフォールバック): ", - "Subscription preferences": "登録チャンネル設定", + "preferences_category_subscription": "登録チャンネル設定", "preferences_annotations_subscribed_label": "デフォルトで登録チャンネルのアノテーションを表示しますか? ", "Redirect homepage to feed: ": "ホームからフィードにリダイレクト: ", "preferences_max_results_label": "フィードに表示する動画の量: ", @@ -106,7 +106,7 @@ "Enable web notifications": "ウェブ通知を有効化", "`x` uploaded a video": "`x` が動画を投稿しました", "`x` is live": "`x` がライブ中です", - "Data preferences": "データ設定", + "preferences_category_data": "データ設定", "Clear watch history": "再生履歴の削除", "Import/export data": "データのインポート/エクスポート", "Change password": "パスワードを変更", @@ -114,7 +114,7 @@ "Manage tokens": "トークンを管理", "Watch history": "再生履歴", "Delete account": "アカウントを削除", - "Administrator preferences": "管理者設定", + "preferences_category_admin": "管理者設定", "preferences_default_home_label": "デフォルトのホーム: ", "preferences_feed_menu_label": "フィードメニュー: ", "preferences_show_nick_label": "ニックネームを一番上に表示する: ", diff --git a/locales/ko.json b/locales/ko.json index 055cc6e8..b96b3c0b 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -3,7 +3,7 @@ "preferences_max_results_label": "피드에 표시된 동영상 수: ", "Redirect homepage to feed: ": "피드로 홈페이지 리디렉션: ", "preferences_annotations_subscribed_label": "구독한 채널에 기본적으로 특수효과를 표시하시겠습니까? ", - "Subscription preferences": "구독 설정", + "preferences_category_subscription": "구독 설정", "preferences_automatic_instance_redirect_label": "자동 인스턴스 리디렉션 (redirect.invidious.io로 대체): ", "preferences_thin_mode_label": "단순 모드: ", "light": "라이트", @@ -11,7 +11,7 @@ "preferences_dark_mode_label": "테마: ", "Dark mode: ": "다크 모드: ", "preferences_player_style_label": "플레이어 스타일: ", - "Visual preferences": "시각 설정", + "preferences_category_visual": "시각 설정", "preferences_vr_mode_label": "인터랙티브 360도 비디오: ", "preferences_extend_desc_label": "자동으로 비디오 설명 확장: ", "preferences_annotations_label": "기본적으로 주석 표시: ", @@ -30,13 +30,13 @@ "preferences_continue_label": "기본적으로 다음 재생: ", "preferences_autoplay_label": "자동재생: ", "preferences_video_loop_label": "항상 반복: ", - "Player preferences": "플레이어 설정", + "preferences_category_player": "플레이어 설정", "Preferences": "설정", "Google verification code": "구글 인증 코드", "E-mail": "이메일", "Register": "회원가입", "Sign In": "로그인", - "Miscellaneous preferences": "기타 설정", + "preferences_category_misc": "기타 설정", "Image CAPTCHA": "이미지 CAPTCHA", "Text CAPTCHA": "텍스트 CAPTCHA", "Time (h:mm:ss):": "시각 (h:mm:ss):", @@ -174,7 +174,7 @@ "preferences_show_nick_label": "상단에 닉네임 표시: ", "preferences_feed_menu_label": "피드 메뉴: ", "preferences_default_home_label": "기본 홈페이지: ", - "Administrator preferences": "관리자 설정", + "preferences_category_admin": "관리자 설정", "Delete account": "계정 삭제", "Watch history": "시청 기록", "Manage tokens": "토큰 관리", @@ -182,7 +182,7 @@ "Change password": "비밀번호 변경", "Import/export data": "데이터 가져오기/내보내기", "Clear watch history": "시청 기록 지우기", - "Data preferences": "데이터 설정", + "preferences_category_data": "데이터 설정", "`x` is live": "`x` 이(가) 라이브 중입니다", "`x` uploaded a video": "`x` 동영상 게시됨", "Enable web notifications": "웹 알림 활성화", diff --git a/locales/lt.json b/locales/lt.json index 9427f78c..cc609126 100644 --- a/locales/lt.json +++ b/locales/lt.json @@ -60,7 +60,7 @@ "E-mail": "El. paštas", "Google verification code": "Google patvirtinimo kodas", "Preferences": "Pasirinktys", - "Player preferences": "Grotuvo pasirinktys", + "preferences_category_player": "Grotuvo pasirinktys", "preferences_video_loop_label": "Visada kartoti: ", "preferences_autoplay_label": "Leisti automatiškai: ", "preferences_continue_label": "Leisti sekantį automatiškai kaip nustatyta: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Rodyti anotacijas pagal nutylėjimą: ", "preferences_extend_desc_label": "Automatiškai išplėsti vaizdo įrašo aprašymą: ", "preferences_vr_mode_label": "Interaktyvūs 360 laipsnių vaizdo įrašai: ", - "Visual preferences": "Vizualinės nuostatos", + "preferences_category_visual": "Vizualinės nuostatos", "preferences_player_style_label": "Vaizdo grotuvo stilius: ", "Dark mode: ": "Tamsus rėžimas: ", "preferences_dark_mode_label": "Tema: ", "dark": "tamsi", "light": "šviesi", "preferences_thin_mode_label": "Sugretintas rėžimas: ", - "Miscellaneous preferences": "Įvairios nuostatos", + "preferences_category_misc": "Įvairios nuostatos", "preferences_automatic_instance_redirect_label": "Automatinis šaltinio nukreipimas (atsarginis nukreipimas į redirect.Invidous.io): ", - "Subscription preferences": "Prenumeratų nuostatos", + "preferences_category_subscription": "Prenumeratų nuostatos", "preferences_annotations_subscribed_label": "Prenumeruojamiems kanalams subtitrus rodyti pagal nutylėjimą: ", "Redirect homepage to feed: ": "Peradresuoti pagrindinį puslapį į kanalų sąrašą: ", "preferences_max_results_label": "Vaizdo įrašų kiekis kanalų sąraše: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Įgalinti žiniatinklio pranešimus", "`x` uploaded a video": "`x` įkėlė vaizdo įrašą", "`x` is live": "`x` transliuoja tiesiogiai", - "Data preferences": "Duomenų parinktys", + "preferences_category_data": "Duomenų parinktys", "Clear watch history": "Išvalyti žiūrėjimo istoriją", "Import/export data": "Importuoti/ eksportuoti duomenis", "Change password": "Pakeisti slaptažodį", @@ -114,7 +114,7 @@ "Manage tokens": "Valdyti žetonus", "Watch history": "Žiūrėjimo istorija", "Delete account": "Ištrinti paskyrą", - "Administrator preferences": "Administratoriaus nuostatos", + "preferences_category_admin": "Administratoriaus nuostatos", "preferences_default_home_label": "Numatytasis pagrindinis puslapis ", "preferences_feed_menu_label": "Kanalų sąrašo meniu: ", "preferences_show_nick_label": "Rodyti slapyvardį viršuje: ", diff --git a/locales/nb-NO.json b/locales/nb-NO.json index a7828015..3dd76c1f 100644 --- a/locales/nb-NO.json +++ b/locales/nb-NO.json @@ -60,7 +60,7 @@ "E-mail": "E-post", "Google verification code": "Google-bekreftelseskode", "Preferences": "Innstillinger", - "Player preferences": "Avspillerinnstillinger", + "preferences_category_player": "Avspillerinnstillinger", "preferences_video_loop_label": "Alltid gjenta: ", "preferences_autoplay_label": "Autoavspilling: ", "preferences_continue_label": "Spill neste som forvalg: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Vis merknader som forvalg? ", "preferences_extend_desc_label": "Utvid videobeskrivelse automatisk: ", "preferences_vr_mode_label": "Interaktive 360-gradersfilmer: ", - "Visual preferences": "Visuelle innstillinger", + "preferences_category_visual": "Visuelle innstillinger", "preferences_player_style_label": "Avspillerstil: ", "Dark mode: ": "Mørk drakt: ", "preferences_dark_mode_label": "Drakt: ", "dark": "Mørk", "light": "Lys", "preferences_thin_mode_label": "Tynt modus: ", - "Miscellaneous preferences": "Ulike innstillinger", + "preferences_category_misc": "Ulike innstillinger", "preferences_automatic_instance_redirect_label": "Automatisk instansomdirigering (faller tilbake til redirect.invidious.io): ", - "Subscription preferences": "Abonnementsinnstillinger", + "preferences_category_subscription": "Abonnementsinnstillinger", "preferences_annotations_subscribed_label": "Vis merknader som forvalg for kanaler det abonneres på? ", "Redirect homepage to feed: ": "Videresend hjemmeside til kilde: ", "preferences_max_results_label": "Antall videoer å vise i kilde: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Skru på nettmerknader", "`x` uploaded a video": "`x` lastet opp en video", "`x` is live": "`x` er pålogget", - "Data preferences": "Datainnstillinger", + "preferences_category_data": "Datainnstillinger", "Clear watch history": "Tøm visningshistorikk", "Import/export data": "Importer/eksporter data", "Change password": "Endre passord", @@ -114,7 +114,7 @@ "Manage tokens": "Behandle symboler", "Watch history": "Visningshistorikk", "Delete account": "Slett konto", - "Administrator preferences": "Administratorinnstillinger", + "preferences_category_admin": "Administratorinnstillinger", "preferences_default_home_label": "Forvalgt hjemmeside: ", "preferences_feed_menu_label": "Kilde-meny: ", "preferences_show_nick_label": "Vis kallenavn på toppen: ", diff --git a/locales/nl.json b/locales/nl.json index 9699377d..e523246b 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -60,7 +60,7 @@ "E-mail": "E-mailadres", "Google verification code": "Google-verificatiecode", "Preferences": "Instellingen", - "Player preferences": "Spelerinstellingen", + "preferences_category_player": "Spelerinstellingen", "preferences_video_loop_label": "Altijd herhalen: ", "preferences_autoplay_label": "Automatisch afspelen: ", "preferences_continue_label": "Standaard volgende video afspelen: ", @@ -79,14 +79,14 @@ "preferences_annotations_label": "Standaard annotaties tonen? ", "preferences_extend_desc_label": "Breid videobeschrijving automatisch uit: ", "preferences_vr_mode_label": "Interactieve 360-graden-video's ", - "Visual preferences": "Visuele instellingen", + "preferences_category_visual": "Visuele instellingen", "preferences_player_style_label": "Speler vormgeving ", "Dark mode: ": "Donkere modus: ", "preferences_dark_mode_label": "Thema: ", "dark": "donker", "light": "licht", "preferences_thin_mode_label": "Smalle modus: ", - "Subscription preferences": "Abonnementsinstellingen", + "preferences_category_subscription": "Abonnementsinstellingen", "preferences_annotations_subscribed_label": "Standaard annotaties tonen voor geabonneerde kanalen? ", "Redirect homepage to feed: ": "Startpagina omleiden naar feed: ", "preferences_max_results_label": "Aantal te tonen video's in feed: ", @@ -104,7 +104,7 @@ "Enable web notifications": "Systemmeldingen inschakelen", "`x` uploaded a video": "`x` heeft een video geüpload", "`x` is live": "`x` zendt nu live uit", - "Data preferences": "Gegevensinstellingen", + "preferences_category_data": "Gegevensinstellingen", "Clear watch history": "Kijkgeschiedenis wissen", "Import/export data": "Gegevens im-/exporteren", "Change password": "Wachtwoord wijzigen", @@ -112,7 +112,7 @@ "Manage tokens": "Toegangssleutels beheren", "Watch history": "Kijkgeschiedenis", "Delete account": "Account verwijderen", - "Administrator preferences": "Beheerdersinstellingen", + "preferences_category_admin": "Beheerdersinstellingen", "preferences_default_home_label": "Standaard startpagina: ", "preferences_feed_menu_label": "Feedmenu: ", "Top enabled: ": "Bovenkant inschakelen? ", diff --git a/locales/pl.json b/locales/pl.json index 252a8dea..05e3adab 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Kod weryfikacyjny Google", "Preferences": "Preferencje", - "Player preferences": "Ustawienia odtwarzacza", + "preferences_category_player": "Ustawienia odtwarzacza", "preferences_video_loop_label": "Zawsze zapętlaj: ", "preferences_autoplay_label": "Autoodtwarzanie: ", "preferences_continue_label": "Domyślnie odtwarzaj następny: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Domyślnie pokazuj adnotacje: ", "preferences_extend_desc_label": "Automatycznie rozwijaj opisy filmów: ", "preferences_vr_mode_label": "Interaktywne filmy 360 stopni: ", - "Visual preferences": "Preferencje Wizualne", + "preferences_category_visual": "Preferencje Wizualne", "preferences_player_style_label": "Styl odtwarzacza: ", "Dark mode: ": "Ciemny motyw: ", "preferences_dark_mode_label": "Motyw: ", "dark": "ciemny", "light": "jasny", "preferences_thin_mode_label": "Tryb minimalny: ", - "Miscellaneous preferences": "Różne preferencje", + "preferences_category_misc": "Różne preferencje", "preferences_automatic_instance_redirect_label": "Automatyczne przekierowanie instancji (powrót do redirect.invidious.io): ", - "Subscription preferences": "Preferencje subskrybcji", + "preferences_category_subscription": "Preferencje subskrybcji", "preferences_annotations_subscribed_label": "Domyślnie wyświetlaj adnotacje dla subskrybowanych kanałów: ", "Redirect homepage to feed: ": "Przekieruj stronę główną do subskrybcji: ", "preferences_max_results_label": "Liczba filmów widoczna na stronie subskrybcji: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Włącz powiadomienia", "`x` uploaded a video": "`x` dodał film", "`x` is live": "'x ' jest na żywo", - "Data preferences": "Preferencje danych", + "preferences_category_data": "Preferencje danych", "Clear watch history": "Wyczyść historię", "Import/export data": "Import/Eksport danych", "Change password": "Zmień hasło", @@ -114,7 +114,7 @@ "Manage tokens": "Zarządzaj tokenami", "Watch history": "Historia", "Delete account": "Usuń konto", - "Administrator preferences": "Preferencje administratora", + "preferences_category_admin": "Preferencje administratora", "preferences_default_home_label": "Domyślna strona główna: ", "preferences_feed_menu_label": "Menu aktualności ", "preferences_show_nick_label": "Pokaż pseudonim na górze: ", diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 16475807..072deff2 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Código de verificação do Google", "Preferences": "Preferências", - "Player preferences": "Preferências do reprodutor", + "preferences_category_player": "Preferências do reprodutor", "preferences_video_loop_label": "Repetir sempre: ", "preferences_autoplay_label": "Reprodução automática: ", "preferences_continue_label": "Sempre reproduzir próximo: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Sempre mostrar anotações: ", "preferences_extend_desc_label": "Estenda automaticamente a descrição do vídeo: ", "preferences_vr_mode_label": "Vídeos interativos de 360 graus: ", - "Visual preferences": "Preferências visuais", + "preferences_category_visual": "Preferências visuais", "preferences_player_style_label": "Estilo do tocador: ", "Dark mode: ": "Modo escuro: ", "preferences_dark_mode_label": "Tema: ", "dark": "escuro", "light": "claro", "preferences_thin_mode_label": "Modo compacto: ", - "Miscellaneous preferences": "Preferências diversas", + "preferences_category_misc": "Preferências diversas", "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (fallback para redirect.invidious.io): ", - "Subscription preferences": "Preferências de inscrições", + "preferences_category_subscription": "Preferências de inscrições", "preferences_annotations_subscribed_label": "Sempre mostrar anotações dos vídeos de canais inscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para o feed: ", "preferences_max_results_label": "Número de vídeos no feed: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Ativar notificações pela web", "`x` uploaded a video": "`x` publicou um novo vídeo", "`x` is live": "`x` está ao vivo", - "Data preferences": "Preferências de dados", + "preferences_category_data": "Preferências de dados", "Clear watch history": "Limpar histórico de reprodução", "Import/export data": "Importar/Exportar dados", "Change password": "Alterar senha", @@ -114,7 +114,7 @@ "Manage tokens": "Gerenciar tokens", "Watch history": "Histórico de reprodução", "Delete account": "Apagar sua conta", - "Administrator preferences": "Preferências de administrador", + "preferences_category_admin": "Preferências de administrador", "preferences_default_home_label": "Página de início padrão: ", "preferences_feed_menu_label": "Menu do feed: ", "preferences_show_nick_label": "Mostrar o nickname no topo: ", diff --git a/locales/pt-PT.json b/locales/pt-PT.json index 5700b1a4..f3952f12 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Código de verificação do Google", "Preferences": "Preferências", - "Player preferences": "Preferências do reprodutor", + "preferences_category_player": "Preferências do reprodutor", "preferences_video_loop_label": "Repetir sempre: ", "preferences_autoplay_label": "Reprodução automática: ", "preferences_continue_label": "Reproduzir sempre o próximo: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Mostrar anotações sempre: ", "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ", "preferences_vr_mode_label": "Vídeos interativos de 360 graus: ", - "Visual preferences": "Preferências visuais", + "preferences_category_visual": "Preferências visuais", "preferences_player_style_label": "Estilo do reprodutor: ", "Dark mode: ": "Modo escuro: ", "preferences_dark_mode_label": "Tema: ", "dark": "escuro", "light": "claro", "preferences_thin_mode_label": "Modo compacto: ", - "Miscellaneous preferences": "Preferências diversas", + "preferences_category_misc": "Preferências diversas", "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ", - "Subscription preferences": "Preferências de subscrições", + "preferences_category_subscription": "Preferências de subscrições", "preferences_annotations_subscribed_label": "Mostrar sempre anotações aos canais subscritos: ", "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ", "preferences_max_results_label": "Quantidade de vídeos nas subscrições: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Ativar notificações pela web", "`x` uploaded a video": "`x` publicou um novo vídeo", "`x` is live": "`x` está em direto", - "Data preferences": "Preferências de dados", + "preferences_category_data": "Preferências de dados", "Clear watch history": "Limpar histórico de reprodução", "Import/export data": "Importar / exportar dados", "Change password": "Alterar palavra-chave", @@ -114,7 +114,7 @@ "Manage tokens": "Gerir tokens", "Watch history": "Histórico de reprodução", "Delete account": "Eliminar conta", - "Administrator preferences": "Preferências de administrador", + "preferences_category_admin": "Preferências de administrador", "preferences_default_home_label": "Página inicial predefinida: ", "preferences_feed_menu_label": "Menu de subscrições: ", "preferences_show_nick_label": "Mostrar nome de utilizador em cima: ", diff --git a/locales/pt.json b/locales/pt.json index 100bcbb7..554569f4 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -11,7 +11,7 @@ "Released under the AGPLv3 on Github.": "Lançado sob a AGPLv3 no Github.", "preferences_show_nick_label": "Mostrar nome de utilizador em cima: ", "preferences_automatic_instance_redirect_label": "Redirecionamento de instância automática (solução de último recurso para redirect.invidious.io): ", - "Miscellaneous preferences": "Preferências diversas", + "preferences_category_misc": "Preferências diversas", "preferences_vr_mode_label": "Vídeos interativos de 360 graus: ", "preferences_extend_desc_label": "Estender automaticamente a descrição do vídeo: ", "next_steps_error_message_go_to_youtube": "Ir ao YouTube", @@ -222,13 +222,13 @@ "CAPTCHA enabled: ": "CAPTCHA ativado: ", "preferences_feed_menu_label": "Menu de subscrições: ", "preferences_default_home_label": "Página inicial predefinida: ", - "Administrator preferences": "Preferências de administrador", + "preferences_category_admin": "Preferências de administrador", "Watch history": "Histórico de reprodução", "Manage tokens": "Gerir tokens", "Manage subscriptions": "Gerir as subscrições", "Change password": "Alterar palavra-chave", "Clear watch history": "Limpar histórico de reprodução", - "Data preferences": "Preferências de dados", + "preferences_category_data": "Preferências de dados", "`x` is live": "`x` está em direto", "`x` uploaded a video": "`x` publicou um novo vídeo", "Enable web notifications": "Ativar notificações pela web", @@ -246,14 +246,14 @@ "preferences_max_results_label": "Quantidade de vídeos nas subscrições: ", "Redirect homepage to feed: ": "Redirecionar página inicial para subscrições: ", "preferences_annotations_subscribed_label": "Mostrar sempre anotações aos canais subscritos: ", - "Subscription preferences": "Preferências de subscrições", + "preferences_category_subscription": "Preferências de subscrições", "preferences_thin_mode_label": "Modo compacto: ", "light": "claro", "dark": "escuro", "preferences_dark_mode_label": "Tema: ", "Dark mode: ": "Modo escuro: ", "preferences_player_style_label": "Estilo do reprodutor: ", - "Visual preferences": "Preferências visuais", + "preferences_category_visual": "Preferências visuais", "preferences_related_videos_label": "Mostrar vídeos relacionados: ", "Fallback captions: ": "Legendas alternativas: ", "preferences_captions_label": "Legendas predefinidas: ", @@ -268,7 +268,7 @@ "preferences_continue_autoplay_label": "Reproduzir próximo vídeo automaticamente: ", "preferences_autoplay_label": "Reprodução automática: ", "preferences_video_loop_label": "Repetir sempre: ", - "Player preferences": "Preferências do reprodutor", + "preferences_category_player": "Preferências do reprodutor", "Preferences": "Preferências", "Google verification code": "Código de verificação do Google", "E-mail": "E-mail", diff --git a/locales/ro.json b/locales/ro.json index 804da9a3..f5927cc9 100644 --- a/locales/ro.json +++ b/locales/ro.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Cod de verificare Google", "Preferences": "Preferințe", - "Player preferences": "Setări de redare", + "preferences_category_player": "Setări de redare", "preferences_video_loop_label": "Reluați videoclipul la nesfârșit: ", "preferences_autoplay_label": "Porniți videoclipurile automat: ", "preferences_continue_label": "Vizionați următoarele videoclipuri în mod implicit: ", @@ -77,14 +77,14 @@ "Fallback captions: ": "Subtitrări alternative: ", "preferences_related_videos_label": "Afișați videoclipurile asemănătoare: ", "preferences_annotations_label": "Afișați adnotările în mod implicit: ", - "Visual preferences": "Preferințele site-ului", + "preferences_category_visual": "Preferințele site-ului", "preferences_player_style_label": "Stilul player-ului : ", "Dark mode: ": "Modul întunecat : ", "preferences_dark_mode_label": "Tema : ", "dark": "întunecat", "light": "luminos", "preferences_thin_mode_label": "Mod lejer: ", - "Subscription preferences": "Preferințele paginii de abonamente", + "preferences_category_subscription": "Preferințele paginii de abonamente", "preferences_annotations_subscribed_label": "Afișați adnotările în mod implicit pentru canalele la care v-ați abonat: ", "Redirect homepage to feed: ": "Redirecționați pagina principală la pagina de abonamente: ", "preferences_max_results_label": "Numărul de videoclipuri afișate pe pagina de abonamente: ", @@ -102,7 +102,7 @@ "Enable web notifications": "Activați notificările web", "`x` uploaded a video": "`x` a publicat un videoclip", "`x` is live": "`x` este în direct", - "Data preferences": "Preferințe legate de date", + "preferences_category_data": "Preferințe legate de date", "Clear watch history": "Ștergeți istoricul videoclipurilor vizionate", "Import/export data": "Importați/exportați datele", "Change password": "Schimbați parola", @@ -110,7 +110,7 @@ "Manage tokens": "Gestionați tokenele", "Watch history": "Istoricul videoclipurilor vizionate", "Delete account": "Ștergeți contul", - "Administrator preferences": "Preferințele Administratorului", + "preferences_category_admin": "Preferințele Administratorului", "preferences_default_home_label": "Pagina principală implicită: ", "preferences_feed_menu_label": "Preferințe legate de pagina de abonamente: ", "Top enabled: ": "Top activat: ", diff --git a/locales/ru.json b/locales/ru.json index 50d74f86..2d8d0bee 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -60,7 +60,7 @@ "E-mail": "Электронная почта", "Google verification code": "Код подтверждения Google", "Preferences": "Настройки", - "Player preferences": "Настройки проигрывателя", + "preferences_category_player": "Настройки проигрывателя", "preferences_video_loop_label": "Всегда повторять: ", "preferences_autoplay_label": "Автовоспроизведение: ", "preferences_continue_label": "Всегда включать следующее видео? ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Всегда показывать аннотации? ", "preferences_extend_desc_label": "Автоматически раскрывать описание видео: ", "preferences_vr_mode_label": "Интерактивные 360-градусные видео: ", - "Visual preferences": "Настройки сайта", + "preferences_category_visual": "Настройки сайта", "preferences_player_style_label": "Стиль проигрывателя: ", "Dark mode: ": "Тёмное оформление: ", "preferences_dark_mode_label": "Тема: ", "dark": "темная", "light": "светлая", "preferences_thin_mode_label": "Облегчённое оформление: ", - "Miscellaneous preferences": "Прочие предпочтения", + "preferences_category_misc": "Прочие предпочтения", "preferences_automatic_instance_redirect_label": "Автоматическое перенаправление экземпляра (резервный вариант redirect.invidious.io): ", - "Subscription preferences": "Настройки подписок", + "preferences_category_subscription": "Настройки подписок", "preferences_annotations_subscribed_label": "Всегда показывать аннотации в видео каналов, на которые вы подписаны? ", "Redirect homepage to feed: ": "Отображать видео с каналов, на которые вы подписаны, как главную страницу: ", "preferences_max_results_label": "Число видео, на которые вы подписаны, в ленте: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Включить уведомления в браузере", "`x` uploaded a video": "`x` разместил видео", "`x` is live": "`x` в прямом эфире", - "Data preferences": "Настройки данных", + "preferences_category_data": "Настройки данных", "Clear watch history": "Очистить историю просмотров", "Import/export data": "Импорт/Экспорт данных", "Change password": "Изменить пароль", @@ -114,7 +114,7 @@ "Manage tokens": "Управлять токенами", "Watch history": "История просмотров", "Delete account": "Удалить аккаунт", - "Administrator preferences": "Администраторские настройки", + "preferences_category_admin": "Администраторские настройки", "preferences_default_home_label": "Главная страница по умолчанию: ", "preferences_feed_menu_label": "Меню ленты видео: ", "preferences_show_nick_label": "Показать ник вверху: ", diff --git a/locales/sk.json b/locales/sk.json index 33ccd8ac..3dafee0a 100644 --- a/locales/sk.json +++ b/locales/sk.json @@ -48,7 +48,7 @@ "E-mail": "E-mail", "Google verification code": "Overovací kód Google", "Preferences": "Nastavenia", - "Player preferences": "Nastavenia prehrávača", + "preferences_category_player": "Nastavenia prehrávača", "preferences_video_loop_label": "Vždy opakovať: ", "preferences_autoplay_label": "Automatické prehrávanie: ", "preferences_continue_autoplay_label": "Automatické prehrávanie nasledujúceho videa: ", @@ -64,14 +64,14 @@ "Fallback captions: ": "Náhradné titulky: ", "preferences_related_videos_label": "Zobraziť súvisiace videá: ", "preferences_annotations_label": "Predvolene zobraziť anotácie: ", - "Visual preferences": "Vizuálne nastavenia", + "preferences_category_visual": "Vizuálne nastavenia", "preferences_player_style_label": "Štýl prehrávača: ", "Dark mode: ": "Tmavý režim: ", "preferences_dark_mode_label": "Téma: ", "dark": "tmavá", "light": "svetlá", "preferences_thin_mode_label": "Tenký režim: ", - "Subscription preferences": "Nastavenia predplatného", + "preferences_category_subscription": "Nastavenia predplatného", "preferences_annotations_subscribed_label": "Predvolene zobraziť anotácie odoberaných kanálov: ", "Redirect homepage to feed: ": "Presmerovanie domovskej stránky na informačný kanál: ", "preferences_max_results_label": "Počet videí zobrazených v informačnom kanáli: ", diff --git a/locales/sr.json b/locales/sr.json index fb00709f..0082bd66 100644 --- a/locales/sr.json +++ b/locales/sr.json @@ -60,7 +60,7 @@ "E-mail": "Е-пошта", "Google verification code": "Google-ов оверни кôд", "Preferences": "Подешавања", - "Player preferences": "Подешавања репродуктора", + "preferences_category_player": "Подешавања репродуктора", "preferences_video_loop_label": "Увек понављај: ", "preferences_autoplay_label": "Самопуштање: ", "preferences_continue_label": "Увек подразумевано пуштај следеће: ", diff --git a/locales/sr_Cyrl.json b/locales/sr_Cyrl.json index 75427555..3a6d6c0b 100644 --- a/locales/sr_Cyrl.json +++ b/locales/sr_Cyrl.json @@ -57,7 +57,7 @@ "E-mail": "Е-пошта", "Google verification code": "Google верификациони кôд", "Preferences": "Подешавања", - "Player preferences": "Подешавања видео плејера", + "preferences_category_player": "Подешавања видео плејера", "preferences_video_loop_label": "Увек понављај: ", "preferences_autoplay_label": "Аутоматско пуштање: ", "preferences_continue_label": "Увек пуштај следеће: ", @@ -74,14 +74,14 @@ "Fallback captions: ": "Алтернативни титлови: ", "preferences_related_videos_label": "Прикажи сличне видее: ", "preferences_annotations_label": "Увек приказуј анотације: ", - "Visual preferences": "Подешавања изгледа", + "preferences_category_visual": "Подешавања изгледа", "preferences_player_style_label": "Стил плејера: ", "Dark mode: ": "Тамни режим: ", "preferences_dark_mode_label": "Тема: ", "dark": "тамна", "light": "светла", "preferences_thin_mode_label": "Узани режим: ", - "Subscription preferences": "Подешавања о праћењима", + "preferences_category_subscription": "Подешавања о праћењима", "preferences_annotations_subscribed_label": "Увек приказуј анотације за канале које пратим: ", "Redirect homepage to feed: ": "Прикажи праћења као почетну страницу: ", "preferences_max_results_label": "Количина приказаних видеа на доводу: ", @@ -99,7 +99,7 @@ "Enable web notifications": "Укључи обавештења преко претраживача", "`x` uploaded a video": "`x`је објавио/ла видео", "`x` is live": "`x` емитује уживо", - "Data preferences": "Подешавања о подацима", + "preferences_category_data": "Подешавања о подацима", "Clear watch history": "Обришите историју прегледања", "Import/export data": "Увезите или извезите податке", "Change password": "Промените лозинку", @@ -107,7 +107,7 @@ "Manage tokens": "Управљајте токенима", "Watch history": "Историја прегледања", "Delete account": "Избришите налог", - "Administrator preferences": "Подешавања администратора", + "preferences_category_admin": "Подешавања администратора", "preferences_default_home_label": "Подразумевана главна страница: ", "preferences_feed_menu_label": "Мени довода: ", "CAPTCHA enabled: ": "CAPTCHA укључена?: ", diff --git a/locales/sv-SE.json b/locales/sv-SE.json index 574a07c5..beacc278 100644 --- a/locales/sv-SE.json +++ b/locales/sv-SE.json @@ -60,7 +60,7 @@ "E-mail": "E-post", "Google verification code": "Google-bekräftelsekod", "Preferences": "Inställningar", - "Player preferences": "Spelarinställningar", + "preferences_category_player": "Spelarinställningar", "preferences_video_loop_label": "Loopa alltid: ", "preferences_autoplay_label": "Autouppspelning: ", "preferences_continue_label": "Spela nästa som förval: ", @@ -79,15 +79,15 @@ "preferences_annotations_label": "Visa länkar-i-videon som förval? ", "preferences_extend_desc_label": "Förläng videobeskrivning automatiskt: ", "preferences_vr_mode_label": "Interaktiva 360-gradervideos: ", - "Visual preferences": "Visuella inställningar", + "preferences_category_visual": "Visuella inställningar", "preferences_player_style_label": "Spelarstil: ", "Dark mode: ": "Mörkt läge: ", "preferences_dark_mode_label": "Tema: ", "dark": "Mörkt", "light": "Ljust", "preferences_thin_mode_label": "Lättviktigt läge: ", - "Miscellaneous preferences": "Övriga inställningar", - "Subscription preferences": "Prenumerationsinställningar", + "preferences_category_misc": "Övriga inställningar", + "preferences_category_subscription": "Prenumerationsinställningar", "preferences_annotations_subscribed_label": "Visa länkar-i-videor som förval för kanaler som prenumereras på? ", "Redirect homepage to feed: ": "Omdirigera hemsida till flöde: ", "preferences_max_results_label": "Antal videor att visa i flödet: ", @@ -105,7 +105,7 @@ "Enable web notifications": "Slå på aviseringar", "`x` uploaded a video": "`x` laddade upp en video", "`x` is live": "`x` sänder live", - "Data preferences": "Datainställningar", + "preferences_category_data": "Datainställningar", "Clear watch history": "Töm visningshistorik", "Import/export data": "Importera/Exportera data", "Change password": "Byt lösenord", @@ -113,7 +113,7 @@ "Manage tokens": "Hantera åtkomst-tokens", "Watch history": "Visningshistorik", "Delete account": "Radera konto", - "Administrator preferences": "Administratörsinställningar", + "preferences_category_admin": "Administratörsinställningar", "preferences_default_home_label": "Förvald hemsida: ", "preferences_feed_menu_label": "Flödesmeny: ", "preferences_show_nick_label": "Visa smeknamn överst: ", diff --git a/locales/tr.json b/locales/tr.json index aed79583..28f46963 100644 --- a/locales/tr.json +++ b/locales/tr.json @@ -60,7 +60,7 @@ "E-mail": "E-posta", "Google verification code": "Google doğrulama kodu", "Preferences": "Tercihler", - "Player preferences": "Oynatıcı tercihleri", + "preferences_category_player": "Oynatıcı tercihleri", "preferences_video_loop_label": "Sürekli döngü: ", "preferences_autoplay_label": "Otomatik oynat: ", "preferences_continue_label": "Öntanımlı olarak sonrakini oynat: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "Öntanımlı olarak ek açıklamaları göster: ", "preferences_extend_desc_label": "Video açıklamasını otomatik olarak genişlet: ", "preferences_vr_mode_label": "Etkileşimli 360 derece videolar: ", - "Visual preferences": "Görsel tercihler", + "preferences_category_visual": "Görsel tercihler", "preferences_player_style_label": "Oynatıcı biçimi: ", "Dark mode: ": "Karanlık mod: ", "preferences_dark_mode_label": "Tema: ", "dark": "karanlık", "light": "aydınlık", "preferences_thin_mode_label": "İnce mod: ", - "Miscellaneous preferences": "Çeşitli tercihler", + "preferences_category_misc": "Çeşitli tercihler", "preferences_automatic_instance_redirect_label": "Otomatik örnek yeniden yönlendirmesi (yedek: redirect.invidious.io): ", - "Subscription preferences": "Abonelik tercihleri", + "preferences_category_subscription": "Abonelik tercihleri", "preferences_annotations_subscribed_label": "Abone olunan kanallar için ek açıklamaları öntanımlı olarak göster: ", "Redirect homepage to feed: ": "Ana sayfayı akışa yönlendir: ", "preferences_max_results_label": "Akışta gösterilen video sayısı: ", @@ -106,7 +106,7 @@ "Enable web notifications": "Ağ bildirimlerini etkinleştir", "`x` uploaded a video": "`x` bir video yükledi", "`x` is live": "`x` canlı yayında", - "Data preferences": "Veri tercihleri", + "preferences_category_data": "Veri tercihleri", "Clear watch history": "İzleme geçmişini temizle", "Import/export data": "Verileri içe/dışa aktar", "Change password": "Parolayı değiştir", @@ -114,7 +114,7 @@ "Manage tokens": "Belirteçleri yönet", "Watch history": "İzleme geçmişi", "Delete account": "Hesap silme", - "Administrator preferences": "Yönetici tercihleri", + "preferences_category_admin": "Yönetici tercihleri", "preferences_default_home_label": "Öntanımlı ana sayfa: ", "preferences_feed_menu_label": "Akış menüsü: ", "preferences_show_nick_label": "Takma adı üstte göster: ", diff --git a/locales/uk.json b/locales/uk.json index da63c941..c8d71e0d 100644 --- a/locales/uk.json +++ b/locales/uk.json @@ -60,7 +60,7 @@ "E-mail": "Електронна пошта", "Google verification code": "Код підтвердження Google", "Preferences": "Налаштування", - "Player preferences": "Налаштування програвача", + "preferences_category_player": "Налаштування програвача", "preferences_video_loop_label": "Завжди повторювати: ", "preferences_autoplay_label": "Автовідтворення: ", "preferences_continue_label": "Завжди вмикати наступне відео: ", @@ -77,14 +77,14 @@ "Fallback captions: ": "Запасна мова субтитрів: ", "preferences_related_videos_label": "Показувати схожі відео? ", "preferences_annotations_label": "Завжди показувати анотації? ", - "Visual preferences": "Налаштування сайту", + "preferences_category_visual": "Налаштування сайту", "preferences_player_style_label": "Стиль програвача: ", "Dark mode: ": "Темне оформлення: ", "preferences_dark_mode_label": "Тема: ", "dark": "темна", "light": "Світла", "preferences_thin_mode_label": "Полегшене оформлення: ", - "Subscription preferences": "Налаштування підписок", + "preferences_category_subscription": "Налаштування підписок", "preferences_annotations_subscribed_label": "Завжди показувати анотації у відео каналів, на які ви підписані? ", "Redirect homepage to feed: ": "Показувати відео з каналів, на які підписані, як головну сторінку: ", "preferences_max_results_label": "Кількість відео з каналів, на які підписані, у потоці: ", @@ -102,7 +102,7 @@ "Enable web notifications": "Ввімкнути сповіщення в браузері", "`x` uploaded a video": "`x` розмістив відео", "`x` is live": "`x` у прямому ефірі", - "Data preferences": "Налаштування даних", + "preferences_category_data": "Налаштування даних", "Clear watch history": "Очистити історію переглядів", "Import/export data": "Імпорт і експорт даних", "Change password": "Змінити пароль", @@ -110,7 +110,7 @@ "Manage tokens": "Керувати токенами", "Watch history": "Історія переглядів", "Delete account": "Видалити обліківку", - "Administrator preferences": "Адміністраторські налаштування", + "preferences_category_admin": "Адміністраторські налаштування", "preferences_default_home_label": "Усталена домашня сторінка: ", "preferences_feed_menu_label": "Меню потоку з відео: ", "Top enabled: ": "Увімкнути топ відео? ", diff --git a/locales/vi.json b/locales/vi.json index e73966ab..e433ad55 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -55,7 +55,7 @@ "E-mail": "E-mail", "Google verification code": "Mã xác minh của Google", "Preferences": "Sở thích", - "Player preferences": "Tùy chọn người chơi", + "preferences_category_player": "Tùy chọn người chơi", "preferences_video_loop_label": "Luôn lặp lại: ", "preferences_autoplay_label": "Tự chạy: ", "preferences_continue_label": "Phát tiếp theo theo mặc định: ", @@ -74,16 +74,16 @@ "preferences_annotations_label": "Hiển thị chú thích theo mặc định: ", "preferences_extend_desc_label": "Tự động mở rộng mô tả video: ", "preferences_vr_mode_label": "Video 360 độ tương tác: ", - "Visual preferences": "Tùy chọn hình ảnh", + "preferences_category_visual": "Tùy chọn hình ảnh", "preferences_player_style_label": "Phong cách người chơi: ", "Dark mode: ": "Chế độ tối: ", "preferences_dark_mode_label": "Chủ đề: ", "dark": "tối", "light": "ánh sáng", "preferences_thin_mode_label": "Chế độ mỏng: ", - "Miscellaneous preferences": "Tùy chọn khác", + "preferences_category_misc": "Tùy chọn khác", "preferences_automatic_instance_redirect_label": "Chuyển hướng phiên bản tự động (dự phòng thành redirect.invidious.io): ", - "Subscription preferences": "Tùy chọn đăng ký", + "preferences_category_subscription": "Tùy chọn đăng ký", "preferences_annotations_subscribed_label": "Hiển thị chú thích theo mặc định cho các kênh đã đăng ký: ", "Redirect homepage to feed: ": "Chuyển hướng trang chủ đến nguồn cấp dữ liệu: ", "preferences_max_results_label": "Số lượng video được hiển thị trong nguồn cấp dữ liệu: ", @@ -101,7 +101,7 @@ "Enable web notifications": "Bật thông báo web", "`x` uploaded a video": "` x` đã tải lên một video", "`x` is live": "` x` đang phát trực tiếp", - "Data preferences": "Tùy chọn dữ liệu", + "preferences_category_data": "Tùy chọn dữ liệu", "Clear watch history": "Xóa lịch sử xem", "Import/export data": "Nhập / xuất dữ liệu", "Change password": "Đổi mật khẩu", @@ -109,7 +109,7 @@ "Manage tokens": "Quản lý mã thông báo", "Watch history": "Lịch sử xem", "Delete account": "Xóa tài khoản", - "Administrator preferences": "Tùy chọn quản trị viên", + "preferences_category_admin": "Tùy chọn quản trị viên", "preferences_default_home_label": "Trang chủ mặc định: ", "preferences_feed_menu_label": "Menu nguồn cấp dữ liệu: ", "preferences_show_nick_label": "Hiển thị biệt hiệu ở trên cùng: ", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index a34d219c..9bb3ae71 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -60,7 +60,7 @@ "E-mail": "E-mail", "Google verification code": "Google 验证代码", "Preferences": "偏好设置", - "Player preferences": "播放器偏好设置", + "preferences_category_player": "播放器偏好设置", "preferences_video_loop_label": "始终循环: ", "preferences_autoplay_label": "自动播放: ", "preferences_continue_label": "默认自动播放下一个视频: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "是否默认显示视频注释: ", "preferences_extend_desc_label": "自动展开视频描述: ", "preferences_vr_mode_label": "互动式 360 度视频: ", - "Visual preferences": "视觉选项", + "preferences_category_visual": "视觉选项", "preferences_player_style_label": "播放器样式: ", "Dark mode: ": "深色模式: ", "preferences_dark_mode_label": "主题: ", "dark": "暗色", "light": "亮色", "preferences_thin_mode_label": "窄页模式: ", - "Miscellaneous preferences": "其他选项", + "preferences_category_misc": "其他选项", "preferences_automatic_instance_redirect_label": "自动实例重定向 (回退到redirect.invidious.io): ", - "Subscription preferences": "订阅设置", + "preferences_category_subscription": "订阅设置", "preferences_annotations_subscribed_label": "默认情况下显示已订阅频道的注释: ", "Redirect homepage to feed: ": "跳转主页到 feed: ", "preferences_max_results_label": "Feed 中显示的视频数量: ", @@ -106,7 +106,7 @@ "Enable web notifications": "启用浏览器通知", "`x` uploaded a video": "`x` 上传了视频", "`x` is live": "`x` 正在直播", - "Data preferences": "数据选项", + "preferences_category_data": "数据选项", "Clear watch history": "清除观看历史", "Import/export data": "导入/导出数据", "Change password": "更改密码", @@ -114,7 +114,7 @@ "Manage tokens": "管理令牌", "Watch history": "观看历史", "Delete account": "删除账户", - "Administrator preferences": "管理员选项", + "preferences_category_admin": "管理员选项", "preferences_default_home_label": "默认主页: ", "preferences_feed_menu_label": "Feed 菜单: ", "preferences_show_nick_label": "在顶部显示昵称: ", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 2021e19a..d27491da 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -60,7 +60,7 @@ "E-mail": "電子郵件", "Google verification code": "Google 驗證碼", "Preferences": "偏好設定", - "Player preferences": "播放器偏好設定", + "preferences_category_player": "播放器偏好設定", "preferences_video_loop_label": "總是循環播放: ", "preferences_autoplay_label": "自動播放: ", "preferences_continue_label": "預設播放下一部: ", @@ -79,16 +79,16 @@ "preferences_annotations_label": "預設顯示註釋: ", "preferences_extend_desc_label": "自動展開影片描述: ", "preferences_vr_mode_label": "互動式 360 度影片: ", - "Visual preferences": "視覺偏好設定", + "preferences_category_visual": "視覺偏好設定", "preferences_player_style_label": "播放器樣式: ", "Dark mode: ": "深色模式: ", "preferences_dark_mode_label": "佈景主題: ", "dark": "深色", "light": "淺色", "preferences_thin_mode_label": "精簡模式: ", - "Miscellaneous preferences": "其他偏好設定", + "preferences_category_misc": "其他偏好設定", "preferences_automatic_instance_redirect_label": "自動站台重新導向(汰退至 redirect.invidious.io): ", - "Subscription preferences": "訂閱偏好設定", + "preferences_category_subscription": "訂閱偏好設定", "preferences_annotations_subscribed_label": "預設為已訂閱的頻道顯示註釋: ", "Redirect homepage to feed: ": "重新導向首頁至 feed: ", "preferences_max_results_label": "顯示在 feed 中的影片數量: ", @@ -106,7 +106,7 @@ "Enable web notifications": "啟用網路通知", "`x` uploaded a video": "`x` 上傳了一部影片", "`x` is live": "`x` 正在直播", - "Data preferences": "資料偏好設定", + "preferences_category_data": "資料偏好設定", "Clear watch history": "清除觀看歷史", "Import/export data": "匯入/匯出資料", "Change password": "變更密碼", @@ -114,7 +114,7 @@ "Manage tokens": "管理 tokens", "Watch history": "觀看歷史", "Delete account": "刪除帳號", - "Administrator preferences": "管理員偏好設定", + "preferences_category_admin": "管理員偏好設定", "preferences_default_home_label": "預設首頁: ", "preferences_feed_menu_label": "Feed 選單: ", "preferences_show_nick_label": "在頂部顯示暱稱: ", diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 99bd5d91..345dc368 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -5,7 +5,7 @@
    - <%= translate(locale, "Player preferences") %> + <%= translate(locale, "preferences_category_player") %>
    @@ -116,7 +116,7 @@ checked<% end %>>
    - <%= translate(locale, "Visual preferences") %> + <%= translate(locale, "preferences_category_visual") %>
    @@ -182,7 +182,7 @@
    <% end %> - <%= translate(locale, "Miscellaneous preferences") %> + <%= translate(locale, "preferences_category_misc") %>
    @@ -190,7 +190,7 @@
    <% if env.get? "user" %> - <%= translate(locale, "Subscription preferences") %> + <%= translate(locale, "preferences_category_subscription") %>
    @@ -239,7 +239,7 @@ <% end %> <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(User).email %> - <%= translate(locale, "Administrator preferences") %> + <%= translate(locale, "preferences_category_admin") %>
    @@ -294,7 +294,7 @@ <% end %> <% if env.get? "user" %> - <%= translate(locale, "Data preferences") %> + <%= translate(locale, "preferences_category_data") %>
    <%= translate(locale, "Clear watch history") %> -- cgit v1.2.3 From 66e7285108363c3c3dcb814bdffb716c14e1724d Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 2 Oct 2021 11:51:15 -0700 Subject: Only use /redirect when automatically redirecting --- src/invidious/routes/misc.cr | 2 +- src/invidious/views/channel.ecr | 6 ++++- src/invidious/views/community.ecr | 6 ++++- src/invidious/views/components/item.ecr | 31 ++++------------------ .../views/components/video-context-buttons.ecr | 21 +++++++++++++++ src/invidious/views/playlist.ecr | 13 ++++++--- src/invidious/views/playlists.ecr | 6 ++++- src/invidious/views/watch.ecr | 4 +++ 8 files changed, 56 insertions(+), 33 deletions(-) create mode 100644 src/invidious/views/components/video-context-buttons.ecr (limited to 'src') diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index 0e6356d0..94d54283 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -42,7 +42,7 @@ module Invidious::Routes::Misc referer = get_referer(env) if !env.get("preferences").as(Preferences).automatic_instance_redirect - return env.redirect("https://redirect.invidious.io#{referer}") + return env.redirect("https://redirect.invidious.io/#{referer}") end instance_url = fetch_random_instance diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 7f797e37..66f1ae10 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -45,7 +45,11 @@
    <%= translate(locale, "View channel on YouTube") %>
    - "><%= translate(locale, "Switch Invidious Instance") %> + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> + "><%= translate(locale, "Switch Invidious Instance") %> + <% else %> + "><%= translate(locale, "Switch Invidious Instance") %> + <% end %>
    <% if !channel.auto_generated %>
    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 15d8ed1e..17bc4f89 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -44,7 +44,11 @@
    <%= translate(locale, "View channel on YouTube") %>
    - "><%= translate(locale, "Switch Invidious Instance") %> + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> + "><%= translate(locale, "Switch Invidious Instance") %> + <% else %> + "><%= translate(locale, "Switch Invidious Instance") %> + <% end %>
    <% if !channel.auto_generated %>
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 5788bf51..a58571aa 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -79,19 +79,8 @@ - + <% endpoint_params = "?v=#{item.id}&list=#{item.plid}" %> + <%= rendered "components/video-context-buttons" %>
    diff --git a/src/invidious/views/components/video-context-buttons.ecr b/src/invidious/views/components/video-context-buttons.ecr new file mode 100644 index 00000000..062c3de0 --- /dev/null +++ b/src/invidious/views/components/video-context-buttons.ecr @@ -0,0 +1,21 @@ + \ No newline at end of file diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 12f93a72..4c23ad98 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -41,9 +41,16 @@ <%= translate(locale, "View playlist on YouTube") %> | - "> - <%= translate(locale, "Switch Invidious Instance") %> - + + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> + "> + <%= translate(locale, "Switch Invidious Instance") %> + + <% else %> + "> + <%= translate(locale, "Switch Invidious Instance") %> + + <% end %>
    <% end %>
    diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 1245256f..74890f5a 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -47,7 +47,11 @@
    - "><%= translate(locale, "Switch Invidious Instance") %> + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> + "><%= translate(locale, "Switch Invidious Instance") %> + <% else %> + "><%= translate(locale, "Switch Invidious Instance") %> + <% end %>
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index cad36e73..928e5645 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -116,7 +116,11 @@ we're going to need to do it here in order to allow for translations. (<%= translate(locale, "Embed") %>)

    + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> "><%= translate(locale, "Switch Invidious Instance") %> + <% else %> + "><%= translate(locale, "Switch Invidious Instance") %> + <% end %>

    <% if !channel.auto_generated %> diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 17bc4f89..f0add06b 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -47,7 +47,7 @@ <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> "><%= translate(locale, "Switch Invidious Instance") %> <% else %> - "><%= translate(locale, "Switch Invidious Instance") %> + <%= translate(locale, "Switch Invidious Instance") %> <% end %>
    <% if !channel.auto_generated %> diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 4c23ad98..d0518de7 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -47,7 +47,7 @@ <%= translate(locale, "Switch Invidious Instance") %> <% else %> - "> + <%= translate(locale, "Switch Invidious Instance") %> <% end %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 74890f5a..12dba088 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -50,7 +50,7 @@ <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> "><%= translate(locale, "Switch Invidious Instance") %> <% else %> - "><%= translate(locale, "Switch Invidious Instance") %> + <%= translate(locale, "Switch Invidious Instance") %> <% end %>
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 928e5645..2f3709dd 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -119,7 +119,7 @@ we're going to need to do it here in order to allow for translations. <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> "><%= translate(locale, "Switch Invidious Instance") %> <% else %> - "><%= translate(locale, "Switch Invidious Instance") %> + <%= translate(locale, "Switch Invidious Instance") %> <% end %>

    -- cgit v1.2.3 From 139786b9ef909c6d36ec37ed90eead15d686e3ee Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 8 Nov 2021 23:52:55 +0100 Subject: i18n: pass only the ISO code string to 'translate()' Don't use the whole Hash everywhere. Also fall back nicely to english string if no translation exists. --- src/invidious.cr | 36 ++++++++++----------- src/invidious/helpers/errors.cr | 18 +++++------ src/invidious/helpers/helpers.cr | 2 +- src/invidious/helpers/i18n.cr | 47 +++++++++++++++------------- src/invidious/helpers/serialized_yt_data.cr | 16 +++++----- src/invidious/routes/api/v1/authenticated.cr | 18 +++++------ src/invidious/routes/api/v1/channels.cr | 12 +++---- src/invidious/routes/api/v1/feeds.cr | 4 +-- src/invidious/routes/api/v1/misc.cr | 6 ++-- src/invidious/routes/api/v1/search.cr | 4 +-- src/invidious/routes/api/v1/videos.cr | 10 +++--- src/invidious/routes/channels.cr | 4 +-- src/invidious/routes/embed.cr | 4 +-- src/invidious/routes/feeds.cr | 18 +++++------ src/invidious/routes/login.cr | 6 ++-- src/invidious/routes/misc.cr | 6 ++-- src/invidious/routes/playlists.cr | 22 ++++++------- src/invidious/routes/preferences.cr | 6 ++-- src/invidious/routes/search.cr | 6 ++-- src/invidious/routes/video_playback.cr | 2 +- src/invidious/routes/watch.cr | 2 +- src/invidious/videos.cr | 4 +-- src/invidious/views/template.ecr | 6 ++-- 23 files changed, 133 insertions(+), 126 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 21a12ff2..ade13608 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -408,7 +408,7 @@ define_video_playback_routes() # Users post "/watch_ajax" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -478,7 +478,7 @@ end # /modify_notifications?receive_all_updates=false&receive_no_updates=false # will "unding" all subscriptions. get "/modify_notifications" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -551,7 +551,7 @@ get "/modify_notifications" do |env| end post "/subscription_ajax" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -621,7 +621,7 @@ post "/subscription_ajax" do |env| end get "/subscription_manager" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -724,7 +724,7 @@ get "/subscription_manager" do |env| end get "/data_control" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" referer = get_referer(env) @@ -739,7 +739,7 @@ get "/data_control" do |env| end post "/data_control" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" referer = get_referer(env) @@ -902,7 +902,7 @@ post "/data_control" do |env| end get "/change_password" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -920,7 +920,7 @@ get "/change_password" do |env| end post "/change_password" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -976,7 +976,7 @@ post "/change_password" do |env| end get "/delete_account" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -994,7 +994,7 @@ get "/delete_account" do |env| end post "/delete_account" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -1028,7 +1028,7 @@ post "/delete_account" do |env| end get "/clear_watch_history" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -1046,7 +1046,7 @@ get "/clear_watch_history" do |env| end post "/clear_watch_history" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -1071,7 +1071,7 @@ post "/clear_watch_history" do |env| end get "/authorize_token" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -1099,7 +1099,7 @@ get "/authorize_token" do |env| end post "/authorize_token" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -1147,7 +1147,7 @@ post "/authorize_token" do |env| end get "/token_manager" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -1165,7 +1165,7 @@ get "/token_manager" do |env| end post "/token_ajax" do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -1225,7 +1225,7 @@ end {"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route| get route do |env| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale # Appears to be a bug in routing, having several routes configured # as `/a/:a`, `/b/:a`, `/c/:a` results in 404 @@ -1347,7 +1347,7 @@ error 404 do |env| end error 500 do |env, ex| - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale error_template(500, ex) end diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index e5c77fbc..d10762c5 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -22,7 +22,7 @@ def github_details(summary : String, content : String) return HTML.escape(details) end -def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) +def error_template_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception) if exception.is_a?(InfoException) return error_template_helper(env, locale, status_code, exception.message || "") end @@ -46,7 +46,7 @@ def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSO return templated "error" end -def error_template_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) +def error_template_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String) env.response.content_type = "text/html" env.response.status_code = status_code error_message = translate(locale, message) @@ -58,7 +58,7 @@ macro error_atom(*args) error_atom_helper(env, locale, {{*args}}) end -def error_atom_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) +def error_atom_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception) if exception.is_a?(InfoException) return error_atom_helper(env, locale, status_code, exception.message || "") end @@ -67,7 +67,7 @@ def error_atom_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::A return "#{exception.inspect_with_backtrace}" end -def error_atom_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) +def error_atom_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String) env.response.content_type = "application/atom+xml" env.response.status_code = status_code return "#{message}" @@ -77,7 +77,7 @@ macro error_json(*args) error_json_helper(env, locale, {{*args}}) end -def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil) +def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil) if exception.is_a?(InfoException) return error_json_helper(env, locale, status_code, exception.message || "", additional_fields) end @@ -90,11 +90,11 @@ def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::A return error_message.to_json end -def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, exception : Exception) +def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception) return error_json_helper(env, locale, status_code, exception, nil) end -def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil) +def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil) env.response.content_type = "application/json" env.response.status_code = status_code error_message = {"error" => message} @@ -104,11 +104,11 @@ def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::A return error_message.to_json end -def error_json_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil, status_code : Int32, message : String) +def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String) error_json_helper(env, locale, status_code, message, nil) end -def error_redirect_helper(env : HTTP::Server::Context, locale : Hash(String, JSON::Any) | Nil) +def error_redirect_helper(env : HTTP::Server::Context, locale : String?) request_path = env.request.path if request_path.starts_with?("/search") || request_path.starts_with?("/watch") || diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index c3b356a9..96a78eb9 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -190,7 +190,7 @@ def create_notification_stream(env, topics, connection_channel) connection = Channel(PQ::Notification).new(8) connection_channel.send({true, connection}) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale since = env.params.query["since"]?.try &.to_i? id = 0 diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 0380ad1e..10622517 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -64,31 +64,36 @@ def load_all_locales return locales end -def translate(locale : Hash(String, JSON::Any) | Nil, translation : String, text : String | Nil = nil) - # if locale && !locale[translation]? - # puts "Could not find translation for #{translation.dump}" - # end +def translate(locale : String?, key : String, text : String | Nil = nil) : String + # Raise an eception if "key" doesn't exist in en-US locale + raise "Invalid translation key \"#{key}\"" unless LOCALES["en-US"].has_key?(key) - if locale && locale[translation]? - case locale[translation] - when .as_h? - match_length = 0 + # Default to english, whenever the locale doesn't exist, + # or the key requested has not been translated + if locale && LOCALES.has_key?(locale) && LOCALES[locale].has_key?(key) + raw_data = LOCALES[locale][key] + else + raw_data = LOCALES["en-US"][key] + end + + case raw_data + when .as_h? + # Init + translation = "" + match_length = 0 - locale[translation].as_h.each do |key, value| - if md = text.try &.match(/#{key}/) - if md[0].size >= match_length - translation = value.as_s - match_length = md[0].size - end + raw_data.as_h.each do |key, value| + if md = text.try &.match(/#{key}/) + if md[0].size >= match_length + translation = value.as_s + match_length = md[0].size end end - when .as_s? - if !locale[translation].as_s.empty? - translation = locale[translation].as_s - end - else - raise "Invalid translation #{translation}" end + when .as_s? + translation = raw_data.as_s + else + raise "Invalid translation \"#{raw_data}\"" end if text @@ -98,7 +103,7 @@ def translate(locale : Hash(String, JSON::Any) | Nil, translation : String, text return translation end -def translate_bool(locale : Hash(String, JSON::Any) | Nil, translation : Bool) +def translate_bool(locale : String?, translation : Bool) case translation when true return translate(locale, "Yes") diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index f92b7b89..bfbc237c 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -64,7 +64,7 @@ struct SearchVideo end end - def to_json(locale : Hash(String, JSON::Any) | Nil, json : JSON::Builder) + def to_json(locale : String?, json : JSON::Builder) json.object do json.field "type", "video" json.field "title", self.title @@ -96,7 +96,7 @@ struct SearchVideo end # TODO: remove the locale and follow the crystal convention - def to_json(locale : Hash(String, JSON::Any) | Nil, _json : Nil) + def to_json(locale : String?, _json : Nil) JSON.build do |json| to_json(locale, json) end @@ -130,7 +130,7 @@ struct SearchPlaylist property videos : Array(SearchPlaylistVideo) property thumbnail : String? - def to_json(locale : Hash(String, JSON::Any) | Nil, json : JSON::Builder) + def to_json(locale : String?, json : JSON::Builder) json.object do json.field "type", "playlist" json.field "title", self.title @@ -161,7 +161,7 @@ struct SearchPlaylist end # TODO: remove the locale and follow the crystal convention - def to_json(locale : Hash(String, JSON::Any) | Nil, _json : Nil) + def to_json(locale : String?, _json : Nil) JSON.build do |json| to_json(locale, json) end @@ -183,7 +183,7 @@ struct SearchChannel property description_html : String property auto_generated : Bool - def to_json(locale : Hash(String, JSON::Any) | Nil, json : JSON::Builder) + def to_json(locale : String?, json : JSON::Builder) json.object do json.field "type", "channel" json.field "author", self.author @@ -214,7 +214,7 @@ struct SearchChannel end # TODO: remove the locale and follow the crystal convention - def to_json(locale : Hash(String, JSON::Any) | Nil, _json : Nil) + def to_json(locale : String?, _json : Nil) JSON.build do |json| to_json(locale, json) end @@ -234,7 +234,7 @@ class Category property description_html : String property badges : Array(Tuple(String, String))? - def to_json(locale : Hash(String, JSON::Any) | Nil, json : JSON::Builder) + def to_json(locale : String?, json : JSON::Builder) json.object do json.field "type", "category" json.field "title", self.title @@ -249,7 +249,7 @@ class Category end # TODO: remove the locale and follow the crystal convention - def to_json(locale : Hash(String, JSON::Any) | Nil, _json : Nil) + def to_json(locale : String?, _json : Nil) JSON.build do |json| to_json(locale, json) end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index cdd9e2f6..aaf728ff 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -36,7 +36,7 @@ module Invidious::Routes::API::V1::Authenticated env.response.content_type = "application/json" user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale max_results = env.params.query["max_results"]?.try &.to_i? max_results ||= user.preferences.max_results @@ -122,7 +122,7 @@ module Invidious::Routes::API::V1::Authenticated end def self.list_playlists(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" user = env.get("user").as(User) @@ -141,7 +141,7 @@ module Invidious::Routes::API::V1::Authenticated def self.create_playlist(env) env.response.content_type = "application/json" user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150) if !title @@ -167,7 +167,7 @@ module Invidious::Routes::API::V1::Authenticated end def self.update_playlist_attribute(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" user = env.get("user").as(User) @@ -200,7 +200,7 @@ module Invidious::Routes::API::V1::Authenticated end def self.delete_playlist(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" user = env.get("user").as(User) @@ -223,7 +223,7 @@ module Invidious::Routes::API::V1::Authenticated end def self.insert_video_into_playlist(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" user = env.get("user").as(User) @@ -281,7 +281,7 @@ module Invidious::Routes::API::V1::Authenticated end def self.delete_video_in_playlist(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" user = env.get("user").as(User) @@ -334,7 +334,7 @@ module Invidious::Routes::API::V1::Authenticated def self.register_token(env) user = env.get("user").as(User) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale case env.request.headers["Content-Type"]? when "application/x-www-form-urlencoded" @@ -396,7 +396,7 @@ module Invidious::Routes::API::V1::Authenticated end def self.unregister_token(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" user = env.get("user").as(User) scopes = env.get("scopes").as(Array(String)) diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index da39661c..8b6df3fd 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -1,6 +1,6 @@ module Invidious::Routes::API::V1::Channels def self.home(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" @@ -124,7 +124,7 @@ module Invidious::Routes::API::V1::Channels end def self.latest(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" @@ -146,7 +146,7 @@ module Invidious::Routes::API::V1::Channels end def self.videos(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" @@ -182,7 +182,7 @@ module Invidious::Routes::API::V1::Channels end def self.playlists(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" @@ -219,7 +219,7 @@ module Invidious::Routes::API::V1::Channels end def self.community(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" @@ -242,7 +242,7 @@ module Invidious::Routes::API::V1::Channels end def self.search(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" diff --git a/src/invidious/routes/api/v1/feeds.cr b/src/invidious/routes/api/v1/feeds.cr index bb8f661b..41865f34 100644 --- a/src/invidious/routes/api/v1/feeds.cr +++ b/src/invidious/routes/api/v1/feeds.cr @@ -1,6 +1,6 @@ module Invidious::Routes::API::V1::Feeds def self.trending(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" @@ -25,7 +25,7 @@ module Invidious::Routes::API::V1::Feeds end def self.popular(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 80b59fd5..1621c9ef 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -1,7 +1,7 @@ module Invidious::Routes::API::V1::Misc # Stats API endpoint for Invidious def self.stats(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" if !CONFIG.statistics_enabled @@ -15,7 +15,7 @@ module Invidious::Routes::API::V1::Misc # user playlists and Invidious playlists. This means that we can't # reasonably split them yet. This should be addressed in APIv2 def self.get_playlist(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" plid = env.params.url["plid"] @@ -84,7 +84,7 @@ module Invidious::Routes::API::V1::Misc end def self.mixes(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index 7234dcdd..a3b6c795 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -1,6 +1,6 @@ module Invidious::Routes::API::V1::Search def self.search(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale region = env.params.query["region"]? env.response.content_type = "application/json" @@ -43,7 +43,7 @@ module Invidious::Routes::API::V1::Search end def self.search_suggestions(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale region = env.params.query["region"]? env.response.content_type = "application/json" diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 1edee29c..4c7179ce 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -1,6 +1,6 @@ module Invidious::Routes::API::V1::Videos def self.videos(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" @@ -20,7 +20,7 @@ module Invidious::Routes::API::V1::Videos end def self.captions(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" @@ -149,7 +149,7 @@ module Invidious::Routes::API::V1::Videos # thumbnails for individual scenes in a video. # See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails def self.storyboards(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" @@ -223,7 +223,7 @@ module Invidious::Routes::API::V1::Videos end def self.annotations(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "text/xml" @@ -293,7 +293,7 @@ module Invidious::Routes::API::V1::Videos end def self.comments(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale region = env.params.query["region"]? env.response.content_type = "application/json" diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 29748cd0..6cb1e1f7 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -104,7 +104,7 @@ module Invidious::Routes::Channels # Redirects brand url channels to a normal /channel/:ucid route def self.brand_redirect(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale # /attribution_link endpoint needs both the `a` and `u` parameter # and in order to avoid detection from YouTube we should only send the required ones @@ -148,7 +148,7 @@ module Invidious::Routes::Channels end private def self.fetch_basic_information(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" if user diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index ffbf8c14..049ee344 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -2,7 +2,7 @@ module Invidious::Routes::Embed def self.redirect(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") begin @@ -26,7 +26,7 @@ module Invidious::Routes::Embed end def self.show(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale id = env.params.url["id"] plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index f4a8467b..9650bcf4 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -6,7 +6,7 @@ module Invidious::Routes::Feeds end def self.playlists(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" referer = get_referer(env) @@ -31,7 +31,7 @@ module Invidious::Routes::Feeds end def self.popular(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale if CONFIG.popular_enabled templated "feeds/popular" @@ -42,7 +42,7 @@ module Invidious::Routes::Feeds end def self.trending(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale trending_type = env.params.query["type"]? trending_type ||= "Default" @@ -60,7 +60,7 @@ module Invidious::Routes::Feeds end def self.subscriptions(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -108,7 +108,7 @@ module Invidious::Routes::Feeds end def self.history(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" referer = get_referer(env) @@ -137,7 +137,7 @@ module Invidious::Routes::Feeds # RSS feeds def self.rss_channel(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.headers["Content-Type"] = "application/atom+xml" env.response.content_type = "application/atom+xml" @@ -209,7 +209,7 @@ module Invidious::Routes::Feeds end def self.rss_private(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.headers["Content-Type"] = "application/atom+xml" env.response.content_type = "application/atom+xml" @@ -253,7 +253,7 @@ module Invidious::Routes::Feeds end def self.rss_playlist(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.headers["Content-Type"] = "application/atom+xml" env.response.content_type = "application/atom+xml" @@ -374,7 +374,7 @@ module Invidious::Routes::Feeds end def self.push_notifications_post(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale token = env.params.url["token"] body = env.request.body.not_nil!.gets_to_end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 562d88e5..2a50561d 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -2,7 +2,7 @@ module Invidious::Routes::Login def self.login_page(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" @@ -31,7 +31,7 @@ module Invidious::Routes::Login end def self.login(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale referer = get_referer(env, "/feed/subscriptions") @@ -491,7 +491,7 @@ module Invidious::Routes::Login end def self.signout(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index 3ea4c272..d6bd9571 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -3,7 +3,7 @@ module Invidious::Routes::Misc def self.home(env) preferences = env.get("preferences").as(Preferences) - locale = LOCALES[preferences.locale]? + locale = preferences.locale user = env.get? "user" case preferences.default_home @@ -29,12 +29,12 @@ module Invidious::Routes::Misc end def self.privacy(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale templated "privacy" end def self.licenses(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale rendered "licenses" end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 21126d7e..7b7bd03f 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -2,7 +2,7 @@ module Invidious::Routes::Playlists def self.new(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -18,7 +18,7 @@ module Invidious::Routes::Playlists end def self.create(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -56,7 +56,7 @@ module Invidious::Routes::Playlists end def self.subscribe(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" referer = get_referer(env) @@ -73,7 +73,7 @@ module Invidious::Routes::Playlists end def self.delete_page(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -96,7 +96,7 @@ module Invidious::Routes::Playlists end def self.delete(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -129,7 +129,7 @@ module Invidious::Routes::Playlists end def self.edit(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -169,7 +169,7 @@ module Invidious::Routes::Playlists end def self.update(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -213,7 +213,7 @@ module Invidious::Routes::Playlists end def self.add_playlist_items_page(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -260,7 +260,7 @@ module Invidious::Routes::Playlists end def self.playlist_ajax(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get? "user" sid = env.get? "sid" @@ -387,7 +387,7 @@ module Invidious::Routes::Playlists end def self.show(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale user = env.get?("user").try &.as(User) referer = get_referer(env) @@ -435,7 +435,7 @@ module Invidious::Routes::Playlists end def self.mix(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale rdid = env.params.query["list"]? if !rdid diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 8793d4e9..edf9e1e7 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -2,7 +2,7 @@ module Invidious::Routes::PreferencesRoute def self.show(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale referer = get_referer(env) @@ -12,7 +12,7 @@ module Invidious::Routes::PreferencesRoute end def self.update(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale referer = get_referer(env) video_loop = env.params.body["video_loop"]?.try &.as(String) @@ -227,7 +227,7 @@ module Invidious::Routes::PreferencesRoute end def self.toggle_theme(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale referer = get_referer(env, unroll: false) redirect = env.params.query["redirect"]? diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 3f1e219f..c256d156 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -2,7 +2,7 @@ module Invidious::Routes::Search def self.opensearch(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/opensearchdescription+xml" XML.build(indent: " ", encoding: "UTF-8") do |xml| @@ -18,7 +18,7 @@ module Invidious::Routes::Search end def self.results(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale query = env.params.query["search_query"]? query ||= env.params.query["q"]? @@ -37,7 +37,7 @@ module Invidious::Routes::Search end def self.search(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale region = env.params.query["region"]? query = env.params.query["search_query"]? diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 5c64f669..cfc25782 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -1,7 +1,7 @@ module Invidious::Routes::VideoPlayback # /videoplayback def self.get_video_playback(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale query_params = env.params.query fvip = query_params["fvip"]? || "3" diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index abcf427e..b24222ff 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -2,7 +2,7 @@ module Invidious::Routes::Watch def self.handle(env) - locale = LOCALES[env.get("preferences").as(Preferences).locale]? + locale = env.get("preferences").as(Preferences).locale region = env.params.query["region"]? if env.params.query.to_s.includes?("%20") || env.params.query.to_s.includes?("+") diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d3e5800c..d6ecd326 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -275,7 +275,7 @@ struct Video end end - def to_json(locale : Hash(String, JSON::Any) | Nil, json : JSON::Builder) + def to_json(locale : String?, json : JSON::Builder) json.object do json.field "type", "video" @@ -475,7 +475,7 @@ struct Video end # TODO: remove the locale and follow the crystal convention - def to_json(locale : Hash(String, JSON::Any) | Nil, _json : Nil) + def to_json(locale : String?, _json : Nil) JSON.build { |json| to_json(locale, json) } end diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 3fb2fe18..5b6e6ab8 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -19,8 +19,10 @@ -<% locale = LOCALES[env.get("preferences").as(Preferences).locale]? %> -<% dark_mode = env.get("preferences").as(Preferences).dark_mode %> +<% + locale = env.get("preferences").as(Preferences).locale + dark_mode = env.get("preferences").as(Preferences).dark_mode +%> -theme"> -- cgit v1.2.3 From a1bb421eec608382ca1cc76c4be9db0a417f6bca Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Nov 2021 16:42:37 +0100 Subject: Remove useless 'hl' parameters on captions URL --- src/invidious/views/components/player.ecr | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index 6418f66b..206ba380 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -32,13 +32,11 @@ <% end %> <% preferred_captions.each do |caption| %> - " - label="<%= caption.name %>"> + <% end %> <% captions.each do |caption| %> - " - label="<%= caption.name %>"> + <% end %> <% end %> -- cgit v1.2.3 From b5b0c58de7e9950e15005e28e6e51ff54bb98156 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 15 Nov 2021 17:17:27 +0100 Subject: Add missing translation for quality selectors --- locales/en-US.json | 16 ++++++++++++++++ src/invidious/views/preferences.ecr | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 036c22f4..de3b375d 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -69,7 +69,23 @@ "preferences_local_label": "Proxy videos: ", "preferences_speed_label": "Default speed: ", "preferences_quality_label": "Preferred video quality: ", + "preferences_quality_option_dash": "DASH (adaptative quality)", + "preferences_quality_option_hd720": "HD720", + "preferences_quality_option_medium": "Medium", + "preferences_quality_option_small": "Small", "preferences_quality_dash_label": "Preferred dash video quality: ", + "preferences_quality_dash_option_auto": "Auto", + "preferences_quality_dash_option_best": "Best", + "preferences_quality_dash_option_worst": "Worst", + "preferences_quality_dash_option_4320p": "4320p", + "preferences_quality_dash_option_2160p": "2160p", + "preferences_quality_dash_option_1440p": "1440p", + "preferences_quality_dash_option_1080p": "1080p", + "preferences_quality_dash_option_720p": "720p", + "preferences_quality_dash_option_480p": "480p", + "preferences_quality_dash_option_360p": "360p", + "preferences_quality_dash_option_240p": "240p", + "preferences_quality_dash_option_144p": "144p", "preferences_volume_label": "Player volume: ", "preferences_comments_label": "Default comments: ", "youtube": "YouTube", diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index f3e36bfa..6bd9e348 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -51,7 +51,7 @@ @@ -62,7 +62,7 @@
    -- cgit v1.2.3 From f29ab53aff8ae0d8dd275a61e4abe1cca1ab5918 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 15 Nov 2021 17:17:49 +0100 Subject: Add other missing translations * on watch page and video cards (search results, playlists, etc...) * on /feed/playlists * in search filters (not normalized in order to avoid collisions with an existing PR that reworks the search filters) --- locales/en-US.json | 14 +++++++++++++- src/invidious/views/components/video-context-buttons.ecr | 2 +- src/invidious/views/feeds/playlists.ecr | 4 ++-- src/invidious/views/watch.ecr | 10 +++++----- 4 files changed, 21 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index de3b375d..a9ae042d 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -90,6 +90,7 @@ "preferences_comments_label": "Default comments: ", "youtube": "YouTube", "reddit": "Reddit", + "invidious": "Invidious", "preferences_captions_label": "Default captions: ", "Fallback captions: ": "Fallback captions: ", "preferences_related_videos_label": "Show related videos: ", @@ -439,6 +440,8 @@ "4k": "4K", "location": "Location", "hdr": "HDR", + "purchased" : "Purchased", + "360" : "360°", "filter": "Filter", "Current version: ": "Current version: ", "next_steps_error_message": "After which you should try to: ", @@ -449,5 +452,14 @@ "footer_source_code": "Source code", "footer_original_source_code": "Original source code", "footer_modfied_source_code": "Modified Source code", - "adminprefs_modified_source_code_url_label": "URL to modified source code repository" + "adminprefs_modified_source_code_url_label": "URL to modified source code repository", + "none": "none", + "videoinfo_started_streaming_x_ago": "Started streaming `x` ago", + "videoinfo_watch_on_youTube": "Watch on YouTube", + "videoinfo_youTube_embed_link": "Embed", + "videoinfo_invidious_embed_link": "Embed Link", + "download_subtitles": "Subtitles - `x` (.vtt)", + "user_created_playlists": "`x` created playlists", + "user_saved_playlists": "`x` saved playlists", + "Video unavailable": "Video unavailable" } diff --git a/src/invidious/views/components/video-context-buttons.ecr b/src/invidious/views/components/video-context-buttons.ecr index daa107f0..ddb6c983 100644 --- a/src/invidious/views/components/video-context-buttons.ecr +++ b/src/invidious/views/components/video-context-buttons.ecr @@ -1,6 +1,6 @@
    - " href="https://www.youtube.com/watch<%=endpoint_params%>"> + " href="https://www.youtube.com/watch<%=endpoint_params%>"> " href="/watch<%=endpoint_params%>&listen=1"> diff --git a/src/invidious/views/feeds/playlists.ecr b/src/invidious/views/feeds/playlists.ecr index 868cfeda..a59344c4 100644 --- a/src/invidious/views/feeds/playlists.ecr +++ b/src/invidious/views/feeds/playlists.ecr @@ -6,7 +6,7 @@
    -

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

    +

    <%= translate(locale, "user_created_playlists", %(#{items_created.size})) %>

    @@ -23,7 +23,7 @@
    -

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

    +

    <%= translate(locale, "user_saved_playlists", %(#{items_saved.size})) %>

    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 9cf00393..b85ea59d 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -103,7 +103,7 @@ we're going to need to do it here in order to allow for translations.

    <% elsif video.live_now %>

    - <%= video.premiere_timestamp.try { |t| translate(locale, "Started streaming `x` ago", recode_date((Time.utc - t).ago, locale)) } %> + <%= video.premiere_timestamp.try { |t| translate(locale, "videoinfo_started_streaming_x_ago", recode_date((Time.utc - t).ago, locale)) } %>

    <% end %>
    @@ -112,8 +112,8 @@ we're going to need to do it here in order to allow for translations.
    - <%= translate(locale, "Watch on YouTube") %> - (<%= translate(locale, "Embed") %>) + <%= translate(locale, "videoinfo_watch_on_youTube") %> + (<%= translate(locale, "videoinfo_youTube_embed_link") %>)

    <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> @@ -123,7 +123,7 @@ we're going to need to do it here in order to allow for translations. <% end %>

    <% if params.annotations %> @@ -189,7 +189,7 @@ we're going to need to do it here in order to allow for translations. <% end %> <% captions.each do |caption| %> <% end %> -- cgit v1.2.3 From bf7952d9c7d1cd7f676cfecb70ba2dbbb0d4c4a5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 21 Nov 2021 01:46:35 +0100 Subject: i18n: log a warning instead of rising an exception This is more user-friendly. TODO: maybe make a compile time flag for testing purposes --- src/invidious/helpers/i18n.cr | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 10622517..ab959c13 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -65,8 +65,10 @@ def load_all_locales end def translate(locale : String?, key : String, text : String | Nil = nil) : String - # Raise an eception if "key" doesn't exist in en-US locale - raise "Invalid translation key \"#{key}\"" unless LOCALES["en-US"].has_key?(key) + # Log a warning if "key" doesn't exist in en-US locale + if !LOCALES["en-US"].has_key?(key) + LOGGER.warn("i18n: Missing translation key \"#{key}\"") + end # Default to english, whenever the locale doesn't exist, # or the key requested has not been translated -- cgit v1.2.3 From 319587e2f11abe53c507d2363ca551d8321b203f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 21 Nov 2021 17:34:17 +0100 Subject: extract_video_info: make sure that the Android player response is valid --- src/invidious/videos.cr | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d3e5800c..4406284f 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -857,8 +857,16 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ else client_config.client_type = YoutubeAPI::ClientType::Android end - stream_data = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) - params["streamingData"] = stream_data["streamingData"]? || JSON::Any.new("") + android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) + + # Sometime, the video is available from the web client, but not on Android, so check + # that here, and fallback to the streaming data from the web client if needed. + # See: https://github.com/iv-org/invidious/issues/2549 + if android_player["playabilityStatus"]["status"] == "OK" + params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("") + else + params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("") + end end {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| -- cgit v1.2.3 From ba48f68fc30990437331791848efe896559f49cd Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 21 Nov 2021 18:16:05 +0100 Subject: allow multiple, successive content-encodings --- src/invidious/yt_backend/youtube_api.cr | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index b26af8d1..977aea04 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -434,11 +434,22 @@ module YoutubeAPI # - https://github.com/iv-org/invidious/issues/2612 # - https://github.com/crystal-lang/crystal/issues/11354 # - case response.headers["Content-Encoding"]? - when "gzip" - body = Compress::Gzip::Reader.new(response.body_io, sync_close: true) - when "deflate" - body = Compress::Deflate::Reader.new(response.body_io, sync_close: true) + if encodings = response.headers["Content-Encoding"]? + io = response.body_io + + # Multiple encodings can be combined, and are listed in the order + # in which they were applied. E.g: "deflate, gzip" means that the + # content must be first "gunzipped", then "defated". + encodings.split(',').reverse.each do |enc| + case enc.strip(' ') + when "gzip" + io = Compress::Gzip::Reader.new(io, sync_close: true) + when "deflate" + io = Compress::Deflate::Reader.new(io, sync_close: true) + end + end + + body = io.gets_to_end else body = response.body end -- cgit v1.2.3 From 80a513baa5e595f62b08d7eed1ac709533fde838 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 24 Nov 2021 01:22:09 +0100 Subject: Use new techniques to get (dis)likes back --- src/invidious/videos.cr | 114 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 79 insertions(+), 35 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d3e5800c..1f9a6bc9 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -877,42 +877,84 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ } ).try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) - primary_results = player_response.try &.["contents"]?.try &.["twoColumnWatchNextResults"]?.try &.["results"]? - .try &.["results"]?.try &.["contents"]? - sentiment_bar = primary_results.try &.as_a.select(&.["videoPrimaryInfoRenderer"]?)[0]? - .try &.["videoPrimaryInfoRenderer"]? - .try &.["sentimentBar"]? - .try &.["sentimentBarRenderer"]? - .try &.["tooltip"]? - .try &.as_s - - likes, dislikes = sentiment_bar.try &.split(" / ", 2).map &.gsub(/\D/, "").to_i64 || {0_i64, 0_i64} - params["likes"] = JSON::Any.new(likes) - params["dislikes"] = JSON::Any.new(dislikes) - - params["descriptionHtml"] = JSON::Any.new(primary_results.try &.as_a.select(&.["videoSecondaryInfoRenderer"]?)[0]? - .try &.["videoSecondaryInfoRenderer"]?.try &.["description"]?.try &.["runs"]? - .try &.as_a.try { |t| content_to_comment_html(t).gsub("\n", "
    ") } || "

    ") - - metadata = primary_results.try &.as_a.select(&.["videoSecondaryInfoRenderer"]?)[0]? - .try &.["videoSecondaryInfoRenderer"]? - .try &.["metadataRowContainer"]? - .try &.["metadataRowContainerRenderer"]? - .try &.["rows"]? - .try &.as_a + # Top level elements + + primary_results = player_response + .dig?("contents", "twoColumnWatchNextResults", "results", "results", "contents") + + video_primary_renderer = primary_results + .try &.as_a.find(&.["videoPrimaryInfoRenderer"]?) + .try &.["videoPrimaryInfoRenderer"] + + video_secondary_renderer = primary_results + .try &.as_a.find(&.["videoSecondaryInfoRenderer"]?) + .try &.["videoSecondaryInfoRenderer"] + + # Likes/dislikes + + toplevel_buttons = video_primary_renderer + .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") + + if toplevel_buttons + likes_button = toplevel_buttons.as_a + .find(&.["toggleButtonRenderer"]["defaultIcon"]["iconType"].as_s.== "LIKE") + .try &.["toggleButtonRenderer"] + + if likes_button + likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?) + .try &.dig?("accessibility", "accessibilityData", "label") + likes = likes_txt.as_s.gsub(/\D/, "").to_i64 if likes_txt + + LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") + LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes + end + + dislikes_button = toplevel_buttons.as_a + .find(&.["toggleButtonRenderer"]["defaultIcon"]["iconType"].as_s.== "DISLIKE") + .try &.["toggleButtonRenderer"] + + if dislikes_button + dislikes_txt = (dislikes_button["defaultText"] || dislikes_button["toggledText"]?) + .try &.dig?("accessibility", "accessibilityData", "label") + dislikes = dislikes_txt.as_s.gsub(/\D/, "").to_i64 if dislikes_txt + + LOGGER.trace("extract_video_info: Found \"dislikes\" button. Button text is \"#{dislikes_txt}\"") + LOGGER.debug("extract_video_info: Dislikes count is #{dislikes}") if dislikes + end + end + + if likes && likes != 0_i64 && (!dislikes || dislikes == 0_i64) + if rating = player_response.dig?("videoDetails", "averageRating").try &.as_f + dislikes = (likes * ((5 - rating)/(rating - 1))).round.to_i64 + LOGGER.debug("extract_video_info: Dislikes count (using fallback method) is #{dislikes}") + end + end + + params["likes"] = JSON::Any.new(likes || 0_i64) + params["dislikes"] = JSON::Any.new(dislikes || 0_i64) + + # Description + + description_html = video_secondary_renderer.try &.dig?("description", "runs") + .try &.as_a.try { |t| content_to_comment_html(t).gsub("\n", "
    ") } + + params["descriptionHtml"] = JSON::Any.new(description_html || "

    ") + + # Video metadata + + metadata = video_secondary_renderer + .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") + .try &.as_a params["genre"] = params["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["category"]? || JSON::Any.new("") params["genreUrl"] = JSON::Any.new(nil) metadata.try &.each do |row| title = row["metadataRowRenderer"]?.try &.["title"]?.try &.["simpleText"]?.try &.as_s - contents = row["metadataRowRenderer"]? - .try &.["contents"]? - .try &.as_a[0]? + contents = row.dig?("metadataRowRenderer", "contents", 0) if title.try &.== "Category" - contents = contents.try &.["runs"]? - .try &.as_a[0]? + contents = contents.try &.["runs"]?.try &.as_a[0]? params["genre"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") params["genreUcid"] = JSON::Any.new(contents.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]? @@ -927,17 +969,19 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end end - author_info = primary_results.try &.as_a.select(&.["videoSecondaryInfoRenderer"]?)[0]? - .try &.["videoSecondaryInfoRenderer"]?.try &.["owner"]?.try &.["videoOwnerRenderer"]? + # Author infos - params["authorThumbnail"] = JSON::Any.new(author_info.try &.["thumbnail"]? - .try &.["thumbnails"]?.try &.as_a[0]?.try &.["url"]? - .try &.as_s || "") + author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") + author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url") + + params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? - .try { |t| t["simpleText"]? || t["runs"]?.try &.[0]?.try &.["text"]? }.try &.as_s.split(" ", 2)[0] || "-") + .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }.try &.as_s.split(" ", 2)[0] || "-") + + # Return data - params + return params end def get_video(id, db, refresh = true, region = nil, force_refresh = false) -- cgit v1.2.3 From 2ea0590b032c397868c77a3920661348e7944731 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 25 Nov 2021 19:46:34 +0100 Subject: i18n: return 'key' if 'key' is not in locales files --- src/invidious/helpers/i18n.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index ab959c13..6e29b498 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -65,9 +65,11 @@ def load_all_locales end def translate(locale : String?, key : String, text : String | Nil = nil) : String - # Log a warning if "key" doesn't exist in en-US locale + # Log a warning if "key" doesn't exist in en-US locale and return + # that key as the text, so this is more or less transparent to the user. if !LOCALES["en-US"].has_key?(key) LOGGER.warn("i18n: Missing translation key \"#{key}\"") + return key end # Default to english, whenever the locale doesn't exist, -- cgit v1.2.3 From ceb1feb3502367ec41c8ae7e28a0cfbd7a2e2170 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 25 Nov 2021 23:16:50 +0100 Subject: likes/dislikes: better fallback management '.to_i64?' instead of '.to_i64' returns nil rather than raising an exception when it's done on an empty string. In some rare cases, rating can be equal to 5. In this case, the value of player_response[videoDetails][averageRating] is an Int and not a Float. --- src/invidious/videos.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 1f9a6bc9..d04c6ecb 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -903,7 +903,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ if likes_button likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?) .try &.dig?("accessibility", "accessibilityData", "label") - likes = likes_txt.as_s.gsub(/\D/, "").to_i64 if likes_txt + likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes @@ -916,7 +916,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ if dislikes_button dislikes_txt = (dislikes_button["defaultText"] || dislikes_button["toggledText"]?) .try &.dig?("accessibility", "accessibilityData", "label") - dislikes = dislikes_txt.as_s.gsub(/\D/, "").to_i64 if dislikes_txt + dislikes = dislikes_txt.as_s.gsub(/\D/, "").to_i64? if dislikes_txt LOGGER.trace("extract_video_info: Found \"dislikes\" button. Button text is \"#{dislikes_txt}\"") LOGGER.debug("extract_video_info: Dislikes count is #{dislikes}") if dislikes @@ -924,7 +924,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end if likes && likes != 0_i64 && (!dislikes || dislikes == 0_i64) - if rating = player_response.dig?("videoDetails", "averageRating").try &.as_f + if rating = player_response.dig?("videoDetails", "averageRating").try { |x| x.as_i64? || x.as_f? } dislikes = (likes * ((5 - rating)/(rating - 1))).round.to_i64 LOGGER.debug("extract_video_info: Dislikes count (using fallback method) is #{dislikes}") end -- cgit v1.2.3 From c6e086c6fff9699a32465da0ce5e4784c981e886 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Sun, 28 Nov 2021 08:41:16 +0000 Subject: Revert "Temporarily fix for #2612" (#2673) --- src/invidious/yt_backend/youtube_api.cr | 45 +++++++++------------------------ 1 file changed, 12 insertions(+), 33 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 977aea04..27f25036 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -404,10 +404,19 @@ module YoutubeAPI url = "#{endpoint}?key=#{client_config.api_key}" headers = HTTP::Headers{ - "Content-Type" => "application/json; charset=UTF-8", - "Accept-Encoding" => "gzip, deflate", + "Content-Type" => "application/json; charset=UTF-8", } + # The normal HTTP client automatically applies accept-encoding: gzip, + # and decompresses. However, explicitly applying it will remove this functionality. + # + # https://github.com/crystal-lang/crystal/issues/11252#issuecomment-929594741 + {% unless flag?(:disable_quic) %} + if CONFIG.use_quic + headers["Accept-Encoding"] = "gzip" + end + {% end %} + # Logging LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}") @@ -425,38 +434,8 @@ module YoutubeAPI ) end - if {{ !flag?(:disable_quic) }} && CONFIG.use_quic - body = response.body - else - # Decompress the body ourselves, when using HTTP::Client given that - # auto-decompress is broken in the Crystal stdlib. - # Read more: - # - https://github.com/iv-org/invidious/issues/2612 - # - https://github.com/crystal-lang/crystal/issues/11354 - # - if encodings = response.headers["Content-Encoding"]? - io = response.body_io - - # Multiple encodings can be combined, and are listed in the order - # in which they were applied. E.g: "deflate, gzip" means that the - # content must be first "gunzipped", then "defated". - encodings.split(',').reverse.each do |enc| - case enc.strip(' ') - when "gzip" - io = Compress::Gzip::Reader.new(io, sync_close: true) - when "deflate" - io = Compress::Deflate::Reader.new(io, sync_close: true) - end - end - - body = io.gets_to_end - else - body = response.body - end - end - # Convert result to Hash - initial_data = JSON.parse(body).as_h + initial_data = JSON.parse(response.body).as_h # Error handling if initial_data.has_key?("error") -- cgit v1.2.3 From de00e86cd53043e8a05c2880628fb093a61b690a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 28 Nov 2021 18:04:12 +0100 Subject: Decompress the response body ourselves Temp fix for #2612 --- src/invidious/yt_backend/youtube_api.cr | 62 +++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 18 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 27f25036..85239e72 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -404,38 +404,33 @@ module YoutubeAPI url = "#{endpoint}?key=#{client_config.api_key}" headers = HTTP::Headers{ - "Content-Type" => "application/json; charset=UTF-8", + "Content-Type" => "application/json; charset=UTF-8", + "Accept-Encoding" => "gzip, deflate", } - # The normal HTTP client automatically applies accept-encoding: gzip, - # and decompresses. However, explicitly applying it will remove this functionality. - # - # https://github.com/crystal-lang/crystal/issues/11252#issuecomment-929594741 - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - headers["Accept-Encoding"] = "gzip" - end - {% end %} - # Logging LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}") LOGGER.trace("YoutubeAPI: POST data: #{data}") # Send the POST request - if client_config.proxy_region - response = YT_POOL.client( - client_config.proxy_region, + if {{ !flag?(:disable_quic) }} && CONFIG.use_quic + # Using QUIC client + response = YT_POOL.client(client_config.proxy_region, &.post(url, headers: headers, body: data.to_json) ) + body = response.body else - response = YT_POOL.client &.post( - url, headers: headers, body: data.to_json - ) + # Using HTTP client + body = YT_POOL.client(client_config.proxy_region) do |client| + client.post(url, headers: headers, body: data.to_json) do |response| + self._decompress(response.body_io, response.headers["Content-Encoding"]?) + end + end end # Convert result to Hash - initial_data = JSON.parse(response.body).as_h + initial_data = JSON.parse(body).as_h # Error handling if initial_data.has_key?("error") @@ -453,4 +448,35 @@ module YoutubeAPI return initial_data end + + #################################################################### + # _decompress(body_io, headers) + # + # Internal function that reads the Content-Encoding headers and + # decompresses the content accordingly. + # + # We decompress the body ourselves (when using HTTP::Client) because + # the auto-decompress feature is broken in the Crystal stdlib. + # + # Read more: + # - https://github.com/iv-org/invidious/issues/2612 + # - https://github.com/crystal-lang/crystal/issues/11354 + # + def _decompress(body_io : IO, encodings : String?) : String + if encodings + # Multiple encodings can be combined, and are listed in the order + # in which they were applied. E.g: "deflate, gzip" means that the + # content must be first "gunzipped", then "defated". + encodings.split(',').reverse.each do |enc| + case enc.strip(' ') + when "gzip" + body_io = Compress::Gzip::Reader.new(body_io, sync_close: true) + when "deflate" + body_io = Compress::Deflate::Reader.new(body_io, sync_close: true) + end + end + end + + return body_io.gets_to_end + end end # End of module -- cgit v1.2.3 From 91f83952223a6a3b25268955b3fca6c6cf562fac Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 28 Nov 2021 23:37:27 +0100 Subject: Typo: missing '?' when looking for key in dislikes_button Co-authored-by: Matthew McGarvey --- src/invidious/videos.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d04c6ecb..c9f6626a 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -914,7 +914,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ .try &.["toggleButtonRenderer"] if dislikes_button - dislikes_txt = (dislikes_button["defaultText"] || dislikes_button["toggledText"]?) + dislikes_txt = (dislikes_button["defaultText"]? || dislikes_button["toggledText"]?) .try &.dig?("accessibility", "accessibilityData", "label") dislikes = dislikes_txt.as_s.gsub(/\D/, "").to_i64? if dislikes_txt -- cgit v1.2.3 From 4436359d0783ca8444467603a820c02372be7e9f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 28 Nov 2021 23:44:37 +0100 Subject: Use dig to get category contents Co-authored-by: Matthew McGarvey --- src/invidious/videos.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index c9f6626a..db94110b 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -954,7 +954,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ contents = row.dig?("metadataRowRenderer", "contents", 0) if title.try &.== "Category" - contents = contents.try &.["runs"]?.try &.as_a[0]? + contents = contents.try &.dig?("runs", 0) params["genre"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") params["genreUcid"] = JSON::Any.new(contents.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]? -- cgit v1.2.3 From 342fc202a7c6f34d57b4dbbd22f5df16781327f2 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 29 Nov 2021 14:53:27 +0100 Subject: Fix #2682 Fix "Missing param name: "q" (KeyError)" https://github.com/iv-org/invidious/issues/2682 --- src/invidious/comments.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index ffdce000..cd1af862 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -575,7 +575,9 @@ def content_to_comment_html(content) url = "/watch?v=#{url.request_target.lstrip('/')}" elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") if url.path == "/redirect" - url = HTTP::Params.parse(url.query.not_nil!)["q"] + # Sometimes, links can be corrupted (why?) so make sure to fallback + # nicely. See https://github.com/iv-org/invidious/issues/2682 + url = HTTP::Params.parse(url.query.not_nil!)["q"]? || "" else url = url.request_target end -- cgit v1.2.3 From 8d4b4cd14c4247f63a6d25dc2f023badc11ab3b5 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Sat, 27 Nov 2021 23:36:31 -0600 Subject: Remove dead code --- spec/helpers_spec.cr | 19 ------------------- src/invidious/comments.cr | 37 ------------------------------------- src/invidious/trending.cr | 10 ---------- 3 files changed, 66 deletions(-) (limited to 'src') diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index 002c8bdd..4215b2bd 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -77,16 +77,6 @@ describe "Helper" do end end - describe "#produce_comment_reply_continuation" do - it "correctly produces a continuation token for replies to a given comment" do - produce_comment_reply_continuation("cIHQWOoJeag", "UCq6VFHwMzcMXbuKyG7SQYIg", "Ugx1IP_wGVv3WtGWcdV4AaABAg").should eq("EiYSC2NJSFFXT29KZWFnwAEByAEB4AEBogINKP___________wFAABgGMk0aSxIaVWd4MUlQX3dHVnYzV3RHV2NkVjRBYUFCQWciAggAKhhVQ3E2VkZId016Y01YYnVLeUc3U1FZSWcyC2NJSFFXT29KZWFnQAFICg%3D%3D") - - produce_comment_reply_continuation("cIHQWOoJeag", "UCq6VFHwMzcMXbuKyG7SQYIg", "Ugza62y_TlmTu9o2RfF4AaABAg").should eq("EiYSC2NJSFFXT29KZWFnwAEByAEB4AEBogINKP___________wFAABgGMk0aSxIaVWd6YTYyeV9UbG1UdTlvMlJmRjRBYUFCQWciAggAKhhVQ3E2VkZId016Y01YYnVLeUc3U1FZSWcyC2NJSFFXT29KZWFnQAFICg%3D%3D") - - produce_comment_reply_continuation("_cE8xSu6swE", "UC1AZY74-dGVPe6bfxFwwEMg", "UgyBUaRGHB9Jmt1dsUZ4AaABAg").should eq("EiYSC19jRTh4U3U2c3dFwAEByAEB4AEBogINKP___________wFAABgGMk0aSxIaVWd5QlVhUkdIQjlKbXQxZHNVWjRBYUFCQWciAggAKhhVQzFBWlk3NC1kR1ZQZTZiZnhGd3dFTWcyC19jRTh4U3U2c3dFQAFICg%3D%3D") - end - end - describe "#produce_channel_community_continuation" do it "correctly produces a continuation token for a channel community" do produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4").should eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D") @@ -107,15 +97,6 @@ describe "Helper" do end end - describe "#extract_plid" do - it "correctly extracts playlist ID from trending URL" do - extract_plid("/feed/trending?bp=4gIuCggvbS8wNHJsZhIiUExGZ3F1TG5MNTlhbVBud2pLbmNhZUp3MDYzZlU1M3Q0cA%3D%3D").should eq("PLFgquLnL59amPnwjKncaeJw063fU53t4p") - extract_plid("/feed/trending?bp=4gIvCgkvbS8wYnp2bTISIlBMaUN2Vkp6QnVwS2tDaFNnUDdGWFhDclo2aEp4NmtlTm0%3D").should eq("PLiCvVJzBupKkChSgP7FXXCrZ6hJx6keNm") - extract_plid("/feed/trending?bp=4gIuCggvbS8wNWpoZxIiUEwzWlE1Q3BOdWxRbUtPUDNJekdsYWN0V1c4dklYX0hFUA%3D%3D").should eq("PL3ZQ5CpNulQmKOP3IzGlactWW8vIX_HEP") - extract_plid("/feed/trending?bp=4gIuCggvbS8wMnZ4bhIiUEx6akZiYUZ6c21NUnFhdEJnVTdPeGNGTkZhQ2hqTkVERA%3D%3D").should eq("PLzjFbaFzsmMRqatBgU7OxcFNFaChjNEDD") - end - end - describe "#sign_token" do it "correctly signs a given hash" do token = { diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index ffdce000..22e63d3a 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -60,8 +60,6 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b case cursor when nil, "" ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by) - # when .starts_with? "Ug" - # ctoken = produce_comment_reply_continuation(id, video.ucid, cursor) when .starts_with? "ADSJ" ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by) else @@ -645,38 +643,3 @@ def produce_comment_continuation(video_id, cursor = "", sort_by = "top") return continuation end - -def produce_comment_reply_continuation(video_id, ucid, comment_id) - object = { - "2:embedded" => { - "2:string" => video_id, - "24:varint" => 1_i64, - "25:varint" => 1_i64, - "28:varint" => 1_i64, - "36:embedded" => { - "5:varint" => -1_i64, - "8:varint" => 0_i64, - }, - }, - "3:varint" => 6_i64, - "6:embedded" => { - "3:embedded" => { - "2:string" => comment_id, - "4:embedded" => { - "1:varint" => 0_i64, - }, - "5:string" => ucid, - "6:string" => video_id, - "8:varint" => 1_i64, - "9:varint" => 10_i64, - }, - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 25bab4d2..1f957081 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -20,13 +20,3 @@ def fetch_trending(trending_type, region, locale) return {trending, plid} end - -def extract_plid(url) - return url.try { |i| URI.parse(i).query } - .try { |i| HTTP::Params.parse(i)["bp"] } - .try { |i| URI.decode_www_form(i) } - .try { |i| Base64.decode(i) } - .try { |i| IO::Memory.new(i) } - .try { |i| Protodec::Any.parse(i) } - .try &.["44:0:embedded"]?.try &.["2:1:string"]?.try &.as_s -end -- cgit v1.2.3 From 7b9d26d68891fa4e9e65b1ef197e30938571daae Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 29 Nov 2021 17:21:26 +0100 Subject: Fix #2670 Fixes "Download widget replaces spaces in filename with +" https://github.com/iv-org/invidious/issues/2670 --- src/invidious/routes/video_playback.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 5c64f669..394b1592 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -240,7 +240,7 @@ module Invidious::Routes::VideoPlayback download_widget = JSON.parse(env.params.query["download_widget"]) id = download_widget["id"].as_s - title = download_widget["title"].as_s + title = URI.decode_www_form(download_widget["title"].as_s) if label = download_widget["label"]? return env.redirect "/api/v1/captions/#{id}?label=#{label}&title=#{title}" -- cgit v1.2.3 From 4aa96ecab9319df15677d16e73351b88ee990050 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 1 Dec 2021 17:32:10 +0100 Subject: Use 'dig()' in 'find()' statements --- src/invidious/videos.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index db94110b..e20fb386 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -897,7 +897,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ if toplevel_buttons likes_button = toplevel_buttons.as_a - .find(&.["toggleButtonRenderer"]["defaultIcon"]["iconType"].as_s.== "LIKE") + .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "LIKE") .try &.["toggleButtonRenderer"] if likes_button @@ -910,7 +910,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end dislikes_button = toplevel_buttons.as_a - .find(&.["toggleButtonRenderer"]["defaultIcon"]["iconType"].as_s.== "DISLIKE") + .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "DISLIKE") .try &.["toggleButtonRenderer"] if dislikes_button -- cgit v1.2.3 From a6a0bbf39834003b9ee26392e626828c05209010 Mon Sep 17 00:00:00 2001 From: bbielsa Date: Mon, 25 Oct 2021 19:50:17 -0400 Subject: Add remember_position field to the Preferences and VideoPreferences structs, and add a checkbox in the preferences page to toggle it --- src/invidious/config.cr | 1 + src/invidious/routes/preferences.cr | 5 +++++ src/invidious/user/preferences.cr | 1 + src/invidious/videos.cr | 6 ++++++ src/invidious/views/preferences.ecr | 5 +++++ 5 files changed, 18 insertions(+) (limited to 'src') diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 578e31fd..bdd670f1 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -42,6 +42,7 @@ struct ConfigPreferences property volume : Int32 = 100 property vr_mode : Bool = true property show_nick : Bool = true + property remember_position : Bool = false def to_tuple {% begin %} diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index edf9e1e7..3bf7e5da 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -70,6 +70,10 @@ module Invidious::Routes::PreferencesRoute vr_mode ||= "off" vr_mode = vr_mode == "on" + remember_position = env.params.body["remember_position"]?.try &.as(String) + remember_position ||= "off" + remember_position = remember_position == "on" + show_nick = env.params.body["show_nick"]?.try &.as(String) show_nick ||= "off" show_nick = show_nick == "on" @@ -165,6 +169,7 @@ module Invidious::Routes::PreferencesRoute extend_desc: extend_desc, vr_mode: vr_mode, show_nick: show_nick, + remember_position: remember_position, }.to_json).to_json if user = env.get? "user" diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr index c15876f5..f2d089b4 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -53,6 +53,7 @@ struct Preferences property video_loop : Bool = CONFIG.default_user_preferences.video_loop property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc property volume : Int32 = CONFIG.default_user_preferences.volume + property remember_position : Bool = CONFIG.default_user_preferences.remember_position module BoolToString def self.to_json(value : String, json : JSON::Builder) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 621ff386..bffd564d 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -246,6 +246,7 @@ struct VideoPreferences property video_start : Float64 | Int32 property volume : Int32 property vr_mode : Bool + property remember_position : Bool end struct Video @@ -1090,6 +1091,7 @@ def process_video_params(query, preferences) extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } volume = query["volume"]?.try &.to_i? vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } + remember_position = query["remember_position"]?.try { |q| (q == "true" || q == "1").to_unsafe } if preferences # region ||= preferences.region @@ -1110,6 +1112,7 @@ def process_video_params(query, preferences) extend_desc ||= preferences.extend_desc.to_unsafe volume ||= preferences.volume vr_mode ||= preferences.vr_mode.to_unsafe + remember_position ||= preferences.remember_position.to_unsafe end annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe @@ -1129,6 +1132,7 @@ def process_video_params(query, preferences) extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe volume ||= CONFIG.default_user_preferences.volume vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe + remember_position ||= CONFIG.default_user_preferences.remember_position.to_unsafe annotations = annotations == 1 autoplay = autoplay == 1 @@ -1140,6 +1144,7 @@ def process_video_params(query, preferences) video_loop = video_loop == 1 extend_desc = extend_desc == 1 vr_mode = vr_mode == 1 + remember_position = remember_position == 1 if CONFIG.disabled?("dash") && quality == "dash" quality = "high" @@ -1190,6 +1195,7 @@ def process_video_params(query, preferences) video_start: video_start, volume: volume, vr_mode: vr_mode, + remember_position: remember_position, }) return params diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 6bd9e348..603c337b 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -116,6 +116,11 @@ checked<% end %>>
    +
    + + checked<% end %>> +
    + <%= translate(locale, "preferences_category_visual") %>
    -- cgit v1.2.3 From 5abe7fe12377096c5b63bc787ccdbb11b8cf78a9 Mon Sep 17 00:00:00 2001 From: bbielsa Date: Tue, 26 Oct 2021 19:25:29 -0400 Subject: Rename 'remember_position' to 'save_player_pos' for clarity --- assets/js/player.js | 10 +++++----- src/invidious/config.cr | 2 +- src/invidious/routes/preferences.cr | 8 ++++---- src/invidious/user/preferences.cr | 2 +- src/invidious/videos.cr | 12 ++++++------ src/invidious/views/preferences.ecr | 4 ++-- 6 files changed, 19 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index 2a0c6fd7..b4973482 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -38,7 +38,7 @@ embed_url.searchParams.delete('v'); short_url = location.origin + '/' + video_data.id + embed_url.search; embed_url = location.origin + '/embed/' + video_data.id + embed_url.search; -var remember_position_key = "remember_position"; +var save_player_pos_key = "save_player_pos"; var shareOptions = { socials: ['fbFeed', 'tw', 'reddit', 'email'], @@ -201,7 +201,7 @@ if (video_data.premiere_timestamp && Math.round(new Date() / 1000) < video_data. player.getChild('bigPlayButton').hide(); } -if (video_data.params.remember_position) { +if (video_data.params.save_player_pos) { const remeberedTime = get_video_time(); let lastUpdated = 0; @@ -384,12 +384,12 @@ function get_video_time() { function set_all_video_times(times) { const json = JSON.stringify(times); - localStorage.setItem(remember_position_key, json); + localStorage.setItem(save_player_pos_key, json); } function get_all_video_times() { try { - const raw = localStorage.getItem(remember_position_key); + const raw = localStorage.getItem(save_player_pos_key); const times = JSON.parse(raw); return times || {}; @@ -400,7 +400,7 @@ function get_all_video_times() { } function remove_all_video_times() { - localStorage.removeItem(remember_position_key); + localStorage.removeItem(save_player_pos_key); } function set_time_percent(percent) { diff --git a/src/invidious/config.cr b/src/invidious/config.cr index bdd670f1..c4a8bf83 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -42,7 +42,7 @@ struct ConfigPreferences property volume : Int32 = 100 property vr_mode : Bool = true property show_nick : Bool = true - property remember_position : Bool = false + property save_player_pos : Bool = false def to_tuple {% begin %} diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 3bf7e5da..a07584c8 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -70,9 +70,9 @@ module Invidious::Routes::PreferencesRoute vr_mode ||= "off" vr_mode = vr_mode == "on" - remember_position = env.params.body["remember_position"]?.try &.as(String) - remember_position ||= "off" - remember_position = remember_position == "on" + save_player_pos = env.params.body["save_player_pos"]?.try &.as(String) + save_player_pos ||= "off" + save_player_pos = save_player_pos == "on" show_nick = env.params.body["show_nick"]?.try &.as(String) show_nick ||= "off" @@ -169,7 +169,7 @@ module Invidious::Routes::PreferencesRoute extend_desc: extend_desc, vr_mode: vr_mode, show_nick: show_nick, - remember_position: remember_position, + save_player_pos: save_player_pos, }.to_json).to_json if user = env.get? "user" diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr index f2d089b4..bf7ea401 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -53,7 +53,7 @@ struct Preferences property video_loop : Bool = CONFIG.default_user_preferences.video_loop property extend_desc : Bool = CONFIG.default_user_preferences.extend_desc property volume : Int32 = CONFIG.default_user_preferences.volume - property remember_position : Bool = CONFIG.default_user_preferences.remember_position + property save_player_pos : Bool = CONFIG.default_user_preferences.save_player_pos module BoolToString def self.to_json(value : String, json : JSON::Builder) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index bffd564d..9b4b54e8 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -246,7 +246,7 @@ struct VideoPreferences property video_start : Float64 | Int32 property volume : Int32 property vr_mode : Bool - property remember_position : Bool + property save_player_pos : Bool end struct Video @@ -1091,7 +1091,7 @@ def process_video_params(query, preferences) extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } volume = query["volume"]?.try &.to_i? vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } - remember_position = query["remember_position"]?.try { |q| (q == "true" || q == "1").to_unsafe } + save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe } if preferences # region ||= preferences.region @@ -1112,7 +1112,7 @@ def process_video_params(query, preferences) extend_desc ||= preferences.extend_desc.to_unsafe volume ||= preferences.volume vr_mode ||= preferences.vr_mode.to_unsafe - remember_position ||= preferences.remember_position.to_unsafe + save_player_pos ||= preferences.save_player_pos.to_unsafe end annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe @@ -1132,7 +1132,7 @@ def process_video_params(query, preferences) extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe volume ||= CONFIG.default_user_preferences.volume vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe - remember_position ||= CONFIG.default_user_preferences.remember_position.to_unsafe + save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe annotations = annotations == 1 autoplay = autoplay == 1 @@ -1144,7 +1144,7 @@ def process_video_params(query, preferences) video_loop = video_loop == 1 extend_desc = extend_desc == 1 vr_mode = vr_mode == 1 - remember_position = remember_position == 1 + save_player_pos = save_player_pos == 1 if CONFIG.disabled?("dash") && quality == "dash" quality = "high" @@ -1195,7 +1195,7 @@ def process_video_params(query, preferences) video_start: video_start, volume: volume, vr_mode: vr_mode, - remember_position: remember_position, + save_player_pos: save_player_pos, }) return params diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 603c337b..b1ec68ad 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -117,8 +117,8 @@
    - - checked<% end %>> + + checked<% end %>>
    <%= translate(locale, "preferences_category_visual") %> -- cgit v1.2.3 From f31bd5ffb9faf770810360476c63e4e5bed6ed67 Mon Sep 17 00:00:00 2001 From: bbielsa Date: Tue, 26 Oct 2021 19:31:50 -0400 Subject: Use localization for save player position label in the preferences page --- locales/en-US.json | 3 ++- src/invidious/views/preferences.ecr | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 8bdf719b..94aac89e 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -461,5 +461,6 @@ "download_subtitles": "Subtitles - `x` (.vtt)", "user_created_playlists": "`x` created playlists", "user_saved_playlists": "`x` saved playlists", - "Video unavailable": "Video unavailable" + "Video unavailable": "Video unavailable", + "preferences_save_player_pos_label": "Save the current video time: " } diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index b1ec68ad..96904259 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -117,7 +117,7 @@
    - + checked<% end %>>
    -- cgit v1.2.3 From b90bceb2dc0e4bd29b9ace6a0b9d413f8eb673da Mon Sep 17 00:00:00 2001 From: bbielsa Date: Wed, 3 Nov 2021 00:06:29 -0400 Subject: Fix formatting of preferences.cr and videos.cr --- src/invidious/routes/preferences.cr | 2 +- src/invidious/videos.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index a07584c8..15c00700 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -169,7 +169,7 @@ module Invidious::Routes::PreferencesRoute extend_desc: extend_desc, vr_mode: vr_mode, show_nick: show_nick, - save_player_pos: save_player_pos, + save_player_pos: save_player_pos, }.to_json).to_json if user = env.get? "user" diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 9b4b54e8..d4ef0900 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1195,7 +1195,7 @@ def process_video_params(query, preferences) video_start: video_start, volume: volume, vr_mode: vr_mode, - save_player_pos: save_player_pos, + save_player_pos: save_player_pos, }) return params -- cgit v1.2.3 From f54e247eb421795f7d7351516978e0f25e8a14f5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 12 Dec 2021 20:58:45 +0100 Subject: Extractors: Add support for shorts Fixes #2708 --- src/invidious/helpers/utils.cr | 13 ++++++++++--- src/invidious/yt_backend/extractors.cr | 22 ++++++++++++++++------ 2 files changed, 26 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 603b4e1f..7bbbcb92 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -21,10 +21,17 @@ def elapsed_text(elapsed) end def decode_length_seconds(string) - length_seconds = string.gsub(/[^0-9:]/, "").split(":").map &.to_i + length_seconds = string.gsub(/[^0-9:]/, "") + return 0_i32 if length_seconds.empty? + + length_seconds = length_seconds.split(":").map { |x| x.to_i? || 0 } length_seconds = [0] * (3 - length_seconds.size) + length_seconds - length_seconds = Time::Span.new hours: length_seconds[0], minutes: length_seconds[1], seconds: length_seconds[2] - length_seconds = length_seconds.total_seconds.to_i + + length_seconds = Time::Span.new( + hours: length_seconds[0], + minutes: length_seconds[1], + seconds: length_seconds[2] + ).total_seconds.to_i32 return length_seconds end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 8398ca8e..66b3cdef 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -49,6 +49,9 @@ private module Parsers if author_info = item_contents.dig?("ownerText", "runs", 0) author = author_info["text"].as_s author_id = HelperExtractors.get_browse_id(author_info) + elsif author_info = item_contents.dig?("shortBylineText", "runs", 0) + author = author_info["text"].as_s + author_id = HelperExtractors.get_browse_id(author_info) else author = author_fallback.name author_id = author_fallback.id @@ -68,18 +71,25 @@ private module Parsers view_count = item_contents.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" - # The length information *should* only always exist in "lengthText". However, the legacy Invidious code - # extracts from "thumbnailOverlays" when it doesn't. More testing is needed to see if this is - # actually needed + # The length information generally exist in "lengthText". However, the info can sometimes + # be retrieved from "thumbnailOverlays" (e.g when the video is a "shorts" one). if length_container = item_contents["lengthText"]? length_seconds = decode_length_seconds(length_container["simpleText"].as_s) elsif length_container = item_contents["thumbnailOverlays"]?.try &.as_a.find(&.["thumbnailOverlayTimeStatusRenderer"]?) # This needs to only go down the `simpleText` path (if possible). If more situations came up that requires # a specific pathway then we should add an argument to extract_text that'll make this possible - length_seconds = length_container.dig?("thumbnailOverlayTimeStatusRenderer", "text", "simpleText") + length_text = length_container.dig?("thumbnailOverlayTimeStatusRenderer", "text", "simpleText") - if length_seconds - length_seconds = decode_length_seconds(length_seconds.as_s) + if length_text + length_text = length_text.as_s + + if length_text == "SHORTS" + # Approximate length to one minute, as "shorts" generally don't exceed that length. + # TODO: Add some sort of metadata for the type of video (normal, live, premiere, shorts) + length_seconds = 60_i32 + else + length_seconds = decode_length_seconds(length_text) + end else length_seconds = 0 end -- cgit v1.2.3 From ddb06b0cac4c0b78e2e8e085791bce4c3a760625 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 19 Dec 2021 20:11:50 +0100 Subject: Fix XSS vulnerability in channel playlists The channel//playlists page was vulnerable to Cross Site Scripting (XSS), because the different URL parameters were inserted as-is in the URL meant for instance switching. This vulnerability could allow an attacker to inject malicious Javascript in the page by tricking the user to click on a crafted link. Bug introduced in commit 66e7285108363c3c3dcb814bdffb716c14e1724d ("Only use /redirect when automatically redirecting"). Thanks to Jack (@testa:cthd.icu on Matrix, @cysea on github) for responsibly reporting this issue! --- src/invidious/views/playlist.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index d0518de7..136981da 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -47,7 +47,7 @@ <%= translate(locale, "Switch Invidious Instance") %> <% else %> - + <%= translate(locale, "Switch Invidious Instance") %> <% end %> -- cgit v1.2.3 From fc2b9031d4850e7ed8eb24e388aeae38b4eb2762 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 22 Dec 2021 00:52:08 +0100 Subject: i18n: Add Serbian back --- src/invidious/helpers/i18n.cr | 72 +++++++++++++++++++++---------------------- 1 file changed, 36 insertions(+), 36 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 6e29b498..fd3ddbad 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,43 +1,43 @@ # "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) [Incomplete] # "eu" => load_locale("eu"), # Basque [Incomplete] # "sk" => load_locale("sk"), # Slovak [Incomplete] -# "sr" => load_locale("sr"), # Serbian [Incomplete] -# "sr_Cyrl" => load_locale("sr_Cyrl"), # Serbian (cyrillic) [Incomplete] LOCALES_LIST = { - "ar" => "العربية", # Arabic - "cs" => "Čeština", # Czech - "da" => "Dansk", # Danish - "de" => "Deutsch", # German - "el" => "Ελληνικά", # Greek - "en-US" => "English", # English - "eo" => "Esperanto", # Esperanto - "es" => "Español", # Spanish - "fa" => "فارسی", # Persian - "fi" => "Suomi", # Finnish - "fr" => "Français", # French - "he" => "עברית", # Hebrew - "hr" => "Hrvatski", # Croatian - "hu-HU" => "Magyar Nyelv", # Hungarian - "id" => "Bahasa Indonesia", # Indonesian - "is" => "Íslenska", # Icelandic - "it" => "Italiano", # Italian - "ja" => "日本語", # Japanese - "ko" => "한국어", # Korean - "lt" => "Lietuvių", # Lithuanian - "nb-NO" => "Norsk bokmål", # Norwegian Bokmål - "nl" => "Nederlands", # Dutch - "pl" => "Polski", # Polish - "pt" => "Português", # Portuguese - "pt-BR" => "Português Brasileiro", # Portuguese (Brazil) - "pt-PT" => "Português de Portugal", # Portuguese (Portugal) - "ro" => "Română", # Romanian - "ru" => "русский", # Russian - "sv-SE" => "Svenska", # Swedish - "tr" => "Türkçe", # Turkish - "uk" => "Українська", # Ukrainian - "vi" => "Tiếng Việt", # Vietnamese - "zh-CN" => "汉语", # Chinese (Simplified) - "zh-TW" => "漢語", # Chinese (Traditional) + "ar" => "العربية", # Arabic + "cs" => "Čeština", # Czech + "da" => "Dansk", # Danish + "de" => "Deutsch", # German + "el" => "Ελληνικά", # Greek + "en-US" => "English", # English + "eo" => "Esperanto", # Esperanto + "es" => "Español", # Spanish + "fa" => "فارسی", # Persian + "fi" => "Suomi", # Finnish + "fr" => "Français", # French + "he" => "עברית", # Hebrew + "hr" => "Hrvatski", # Croatian + "hu-HU" => "Magyar Nyelv", # Hungarian + "id" => "Bahasa Indonesia", # Indonesian + "is" => "Íslenska", # Icelandic + "it" => "Italiano", # Italian + "ja" => "日本語", # Japanese + "ko" => "한국어", # Korean + "lt" => "Lietuvių", # Lithuanian + "nb-NO" => "Norsk bokmål", # Norwegian Bokmål + "nl" => "Nederlands", # Dutch + "pl" => "Polski", # Polish + "pt" => "Português", # Portuguese + "pt-BR" => "Português Brasileiro", # Portuguese (Brazil) + "pt-PT" => "Português de Portugal", # Portuguese (Portugal) + "ro" => "Română", # Romanian + "ru" => "русский", # Russian + "sr" => "srpski (latinica)", # Serbian (Latin) + "sr_Cyrl" => "српски (ћирилица)", # Serbian (Cyrillic) + "sv-SE" => "Svenska", # Swedish + "tr" => "Türkçe", # Turkish + "uk" => "Українська", # Ukrainian + "vi" => "Tiếng Việt", # Vietnamese + "zh-CN" => "汉语", # Chinese (Simplified) + "zh-TW" => "漢語", # Chinese (Traditional) } LOCALES = load_all_locales() -- cgit v1.2.3 From 998edba6f064eb4e09ca286ad33bfd967ef03e66 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 26 Nov 2021 19:36:31 +0100 Subject: Move DB queries related to 'videos' in a separate module --- src/invidious.cr | 3 ++- src/invidious/database/base.cr | 4 ++++ src/invidious/database/videos.cr | 43 ++++++++++++++++++++++++++++++++++++++++ src/invidious/videos.cr | 10 ++++------ 4 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 src/invidious/database/base.cr create mode 100644 src/invidious/database/videos.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index ade13608..405fcadf 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -20,12 +20,13 @@ require "kemal" require "athena-negotiation" require "openssl/hmac" require "option_parser" -require "pg" require "sqlite3" require "xml" require "yaml" require "compress/zip" require "protodec/utils" + +require "./invidious/database/*" require "./invidious/helpers/*" require "./invidious/yt_backend/*" require "./invidious/*" diff --git a/src/invidious/database/base.cr b/src/invidious/database/base.cr new file mode 100644 index 00000000..055a6284 --- /dev/null +++ b/src/invidious/database/base.cr @@ -0,0 +1,4 @@ +require "pg" + +module Invidious::Database +end diff --git a/src/invidious/database/videos.cr b/src/invidious/database/videos.cr new file mode 100644 index 00000000..e1fa01c3 --- /dev/null +++ b/src/invidious/database/videos.cr @@ -0,0 +1,43 @@ +require "./base.cr" + +module Invidious::Database::Videos + extend self + + def insert(video : Video) + request = <<-SQL + INSERT INTO videos + VALUES ($1, $2, $3) + ON CONFLICT (id) DO NOTHING + SQL + + PG_DB.exec(request, video.id, video.info.to_json, video.updated) + end + + def delete(id) + request = <<-SQL + DELETE FROM videos * + WHERE id = $1 + SQL + + PG_DB.exec(request, id) + end + + def update(video : Video) + request = <<-SQL + UPDATE videos + SET (id, info, updated) = ($1, $2, $3) + WHERE id = $1 + SQL + + PG_DB.exec(request, video.id, video.info.to_json, video.updated) + end + + def select(id : String) : Video? + request = <<-SQL + SELECT * FROM videos + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: Video) + end +end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d4ef0900..645d3678 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -994,7 +994,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end def get_video(id, db, refresh = true, region = nil, force_refresh = false) - if (video = db.query_one?("SELECT * FROM videos WHERE id = $1", id, as: Video)) && !region + if (video = Invidious::Database::Videos.select(id)) && !region # If record was last updated over 10 minutes ago, or video has since premiered, # refresh (expire param in response lasts for 6 hours) if (refresh && @@ -1003,17 +1003,15 @@ def get_video(id, db, refresh = true, region = nil, force_refresh = false) force_refresh begin video = fetch_video(id, region) - db.exec("UPDATE videos SET (id, info, updated) = ($1, $2, $3) WHERE id = $1", video.id, video.info.to_json, video.updated) + Invidious::Database::Videos.update(video) rescue ex - db.exec("DELETE FROM videos * WHERE id = $1", id) + Invidious::Database::Videos.delete(id) raise ex end end else video = fetch_video(id, region) - if !region - db.exec("INSERT INTO videos VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", video.id, video.info.to_json, video.updated) - end + Invidious::Database::Videos.insert(video) if !region end return video -- cgit v1.2.3 From 3deafe9f8da2805ab19900fbdfb4e90d0a2cea03 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 30 Nov 2021 02:24:24 +0100 Subject: Move DB queries related to playlists in a separate module (1/3) --- src/invidious.cr | 7 +-- src/invidious/database/playlists.cr | 94 ++++++++++++++++++++++++++++ src/invidious/playlists.cr | 10 +-- src/invidious/routes/api/v1/authenticated.cr | 14 ++--- src/invidious/routes/playlists.cr | 14 ++--- 5 files changed, 108 insertions(+), 31 deletions(-) create mode 100644 src/invidious/database/playlists.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 405fcadf..28d8ddac 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -816,11 +816,8 @@ post "/data_control" do |env| 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 = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist.id) + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) end end end diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr new file mode 100644 index 00000000..037e25b7 --- /dev/null +++ b/src/invidious/database/playlists.cr @@ -0,0 +1,94 @@ +require "./base.cr" + +# +# This module contains functions related to the "playlists" table. +# +module Invidious::Database::Playlists + extend self + + # ------------------- + # Insert / delete + # ------------------- + + def insert(playlist : InvidiousPlaylist) + playlist_array = playlist.to_a + + request = <<-SQL + INSERT INTO playlists + VALUES (#{arg_array(playlist_array)}) + SQL + + PG_DB.exec(request, args: playlist_array) + end + + # this function is a bit special: it will also remove all videos + # related to the given playlist ID in the "playlist_videos" table, + # in addition to deleting said ID from "playlists". + def delete(id : String) + request = <<-SQL + DELETE FROM playlist_videos * WHERE plid = $1; + DELETE FROM playlists * WHERE id = $1 + SQL + + PG_DB.exec(request, id) + end + + # ------------------- + # Update + # ------------------- + + def update_video_added(id : String, index : String | Int64) + request = <<-SQL + UPDATE playlists + SET index = array_append(index, $1), + video_count = cardinality(index) + 1, + updated = $2 + WHERE id = $3 + SQL + + PG_DB.exec(request, index, Time.utc, id) + end + + def update_video_removed(id : String, index : String | Int64) + request = <<-SQL + UPDATE playlists + SET index = array_remove(index, $1), + video_count = cardinality(index) - 1, + updated = $2 + WHERE id = $3 + SQL + + PG_DB.exec(request, index, Time.utc, id) + end +end + +# +# This module contains functions related to the "playlist_videos" table. +# +module Invidious::Database::PlaylistVideos + extend self + + # ------------------- + # Insert / Delete + # ------------------- + + def insert(video : PlaylistVideo) + video_array = video.to_a + + request = <<-SQL + INSERT INTO playlist_videos + VALUES (#{arg_array(video_array)}) + SQL + + PG_DB.exec(request, args: video_array) + end + + def delete(index) + request = <<-SQL + DELETE FROM playlist_videos * + WHERE index = $1 + SQL + + PG_DB.exec(request, index) + end +end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index f37667b5..685fa1c7 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -261,10 +261,7 @@ def create_playlist(db, title, privacy, user) index: [] of Int64, }) - playlist_array = playlist.to_a - args = arg_array(playlist_array) - - db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array) + Invidious::Database::Playlists.insert(playlist) return playlist end @@ -282,10 +279,7 @@ def subscribe_playlist(db, user, playlist) index: [] of Int64, }) - playlist_array = playlist.to_a - args = arg_array(playlist_array) - - db.exec("INSERT INTO playlists VALUES (#{args})", args: playlist_array) + Invidious::Database::Playlists.insert(playlist) return playlist end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index aaf728ff..4fe8cd30 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -216,8 +216,7 @@ module Invidious::Routes::API::V1::Authenticated return error_json(403, "Invalid user") end - PG_DB.exec("DELETE FROM playlist_videos * WHERE plid = $1", plid) - PG_DB.exec("DELETE FROM playlists * WHERE id = $1", plid) + Invidious::Database::Playlists.delete(plid) env.response.status_code = 204 end @@ -266,11 +265,8 @@ module Invidious::Routes::API::V1::Authenticated 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 = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, plid) + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(plid, playlist_video.index) 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 @@ -302,8 +298,8 @@ module Invidious::Routes::API::V1::Authenticated return error_json(404, "Playlist does not contain index") 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 = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, plid) + Invidious::Database::PlaylistVideos.delete(index) + Invidious::Database::Playlists.update_video_removed(plid, index) env.response.status_code = 204 end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 7b7bd03f..d29aef09 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -122,8 +122,7 @@ module Invidious::Routes::Playlists return 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) + Invidious::Database::Playlists.delete(plid) env.redirect "/feed/playlists" end @@ -363,15 +362,12 @@ module Invidious::Routes::Playlists 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 = cardinality(index) + 1, updated = $2 WHERE id = $3", playlist_video.index, Time.utc, playlist_id) + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist_id, playlist_video.index) 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 = cardinality(index) - 1, updated = $2 WHERE id = $3", index, Time.utc, playlist_id) + Invidious::Database::PlaylistVideos.delete(index) + Invidious::Database::Playlists.update_video_removed(playlist_id, index) when "action_move_video_before" # TODO: Playlist stub else -- cgit v1.2.3 From 46d08237c6979912275b416a9294a807e5598bc5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 30 Nov 2021 03:11:21 +0100 Subject: Move DB queries related to playlists in a separate module (2/3) --- src/invidious.cr | 2 +- src/invidious/database/playlists.cr | 80 ++++++++++++++++++++++++++++ src/invidious/playlists.cr | 2 +- src/invidious/routes/api/v1/authenticated.cr | 20 ++++--- src/invidious/routes/feeds.cr | 4 +- src/invidious/routes/playlists.cr | 18 ++++--- src/invidious/views/playlist.ecr | 2 +- 7 files changed, 108 insertions(+), 20 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 28d8ddac..93b3357a 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -790,7 +790,7 @@ post "/data_control" do |env| next if !privacy playlist = create_playlist(PG_DB, title, privacy, user) - PG_DB.exec("UPDATE playlists SET description = $1 WHERE id = $2", description, playlist.id) + Invidious::Database::Playlists.update_description(playlist.id, description) videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index 037e25b7..1dba64f3 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -37,6 +37,36 @@ module Invidious::Database::Playlists # Update # ------------------- + def update(id : String, title : String, privacy, description, updated) + request = <<-SQL + UPDATE playlists + SET title = $1, privacy = $2, description = $3, updated = $4 + WHERE id = $5 + SQL + + PG_DB.exec(request, title, privacy, description, updated, id) + end + + def update_description(id : String, description) + request = <<-SQL + UPDATE playlists + SET description = $1 + WHERE id = $2 + SQL + + PG_DB.exec(request, description, id) + end + + def update_subscription_time(id : String) + request = <<-SQL + UPDATE playlists + SET subscribed = $1 + WHERE id = $2 + SQL + + PG_DB.exec(request, Time.utc, id) + end + def update_video_added(id : String, index : String | Int64) request = <<-SQL UPDATE playlists @@ -60,6 +90,56 @@ module Invidious::Database::Playlists PG_DB.exec(request, index, Time.utc, id) end + + # ------------------- + # Salect + # ------------------- + + def select(*, id : String, raise_on_fail : Bool = false) : InvidiousPlaylist? + request = <<-SQL + SELECT * FROM playlists + WHERE id = $1 + SQL + + if raise_on_fail + return PG_DB.query_one(request, id, as: InvidiousPlaylist) + else + return PG_DB.query_one?(request, id, as: InvidiousPlaylist) + end + end + + def select_all(*, author : String) : Array(InvidiousPlaylist) + request = <<-SQL + SELECT * FROM playlists + WHERE author = $1 + SQL + + return PG_DB.query_all(request, author, as: InvidiousPlaylist) + end + + # ------------------- + # Misc checks + # ------------------- + + # Check if given playlist ID exists + def exists?(id : String) : Bool + request = <<-SQL + SELECT id FROM playlists + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: String).nil? + end + + # Count how many playlist a user has created. + def count_owned_by(author : String) : Int64 + request = <<-SQL + SELECT count(*) FROM playlists + WHERE author = $1 + SQL + + return PG_DB.query_one?(request, author, as: Int64) || 0_i64 + end end # diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 685fa1c7..f68dc3b0 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -323,7 +323,7 @@ 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) + if playlist = Invidious::Database::Playlists.select(id: plid) return playlist else raise InfoException.new("Playlist does not exist.") diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 4fe8cd30..d74dca5c 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -127,7 +127,7 @@ module Invidious::Routes::API::V1::Authenticated 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) + playlists = Invidious::Database::Playlists.select_all(author: user.email) JSON.build do |json| json.array do @@ -153,7 +153,7 @@ module Invidious::Routes::API::V1::Authenticated return error_json(400, "Invalid privacy setting.") end - if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + if Invidious::Database::Playlists.count_owned_by(user.email) >= 100 return error_json(400, "User cannot have more than 100 playlists.") end @@ -172,9 +172,12 @@ module Invidious::Routes::API::V1::Authenticated env.response.content_type = "application/json" user = env.get("user").as(User) - plid = env.params.url["plid"] + plid = env.params.url["plid"]? + if !plid || plid.empty? + return error_json(400, "A playlist ID is required") + end - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email && playlist.privacy.private? return error_json(404, "Playlist does not exist.") end @@ -195,7 +198,8 @@ module Invidious::Routes::API::V1::Authenticated 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) + Invidious::Database::Playlists.update(plid, title, privacy, description, updated) + env.response.status_code = 204 end @@ -207,7 +211,7 @@ module Invidious::Routes::API::V1::Authenticated plid = env.params.url["plid"] - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email && playlist.privacy.private? return error_json(404, "Playlist does not exist.") end @@ -229,7 +233,7 @@ module Invidious::Routes::API::V1::Authenticated plid = env.params.url["plid"] - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email && playlist.privacy.private? return error_json(404, "Playlist does not exist.") end @@ -285,7 +289,7 @@ module Invidious::Routes::API::V1::Authenticated 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) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email && playlist.privacy.private? return error_json(404, "Playlist does not exist.") end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 9650bcf4..6424ab47 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -264,7 +264,7 @@ module Invidious::Routes::Feeds path = env.request.path if plid.starts_with? "IV" - if playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if playlist = Invidious::Database::Playlists.select(id: plid) videos = get_playlist_videos(PG_DB, playlist, offset: 0, locale: locale) return XML.build(indent: " ", encoding: "UTF-8") do |xml| @@ -364,7 +364,7 @@ module Invidious::Routes::Feeds if ucid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["channel_id"]? PG_DB.exec("UPDATE channels SET subscribed = $1 WHERE id = $2", Time.utc, ucid) elsif plid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["playlist_id"]? - PG_DB.exec("UPDATE playlists SET subscribed = $1 WHERE id = $2", Time.utc, ucid) + Invidious::Database::Playlists.update_subscription_time(plid) else haltf env, status_code: 400 end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index d29aef09..b73782d5 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -46,7 +46,7 @@ module Invidious::Routes::Playlists return error_template(400, "Invalid privacy setting.") end - if PG_DB.query_one("SELECT count(*) FROM playlists WHERE author = $1", user.email, as: Int64) >= 100 + if Invidious::Database::Playlists.count_owned_by(user.email) >= 100 return error_template(400, "User cannot have more than 100 playlists.") end @@ -85,7 +85,11 @@ module Invidious::Routes::Playlists sid = sid.as(String) plid = env.params.query["list"]? - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + if !plid || plid.empty? + return error_template(400, "A playlist ID is required") + end + + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email return env.redirect referer end @@ -117,7 +121,7 @@ module Invidious::Routes::Playlists return error_template(400, ex) end - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email return env.redirect referer end @@ -148,7 +152,7 @@ module Invidious::Routes::Playlists page ||= 1 begin - playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid, raise_on_fail: true) if !playlist || playlist.author != user.email return env.redirect referer end @@ -189,7 +193,7 @@ module Invidious::Routes::Playlists return error_template(400, ex) end - playlist = PG_DB.query_one?("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid) if !playlist || playlist.author != user.email return env.redirect referer end @@ -206,7 +210,7 @@ module Invidious::Routes::Playlists 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) + Invidious::Database::Playlists.update(plid, title, privacy, description, updated) env.redirect "/playlist?list=#{plid}" end @@ -232,7 +236,7 @@ module Invidious::Routes::Playlists page ||= 1 begin - playlist = PG_DB.query_one("SELECT * FROM playlists WHERE id = $1", plid, as: InvidiousPlaylist) + playlist = Invidious::Database::Playlists.select(id: plid, raise_on_fail: true) if !playlist || playlist.author != user.email return env.redirect referer end diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 136981da..7825b1f0 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -61,7 +61,7 @@
    <% else %> - <% if PG_DB.query_one?("SELECT id FROM playlists WHERE id = $1", playlist.id, as: String).nil? %> + <% if Invidious::Database::Playlists.exists?(playlist.id) %>
    <% else %>
    -- cgit v1.2.3 From d94d4c204548eff69ff7c310782c291c89c83bb2 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 1 Dec 2021 22:05:21 +0100 Subject: Move DB queries related to statistics in a separate module --- src/invidious/database/statistics.cr | 49 ++++++++++++++++++++++++++++ src/invidious/jobs/statistics_refresh_job.cr | 10 +++--- 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 src/invidious/database/statistics.cr (limited to 'src') diff --git a/src/invidious/database/statistics.cr b/src/invidious/database/statistics.cr new file mode 100644 index 00000000..1df549e2 --- /dev/null +++ b/src/invidious/database/statistics.cr @@ -0,0 +1,49 @@ +require "./base.cr" + +module Invidious::Database::Statistics + extend self + + # ------------------- + # User stats + # ------------------- + + def count_users_total : Int64 + request = <<-SQL + SELECT count(*) FROM users + SQL + + PG_DB.query_one(request, as: Int64) + end + + def count_users_active_1m : Int64 + request = <<-SQL + SELECT count(*) FROM users + WHERE CURRENT_TIMESTAMP - updated < '6 months' + SQL + + PG_DB.query_one(request, as: Int64) + end + + def count_users_active_6m : Int64 + request = <<-SQL + SELECT count(*) FROM users + WHERE CURRENT_TIMESTAMP - updated < '1 month' + SQL + + PG_DB.query_one(request, as: Int64) + end + + # ------------------- + # Channel stats + # ------------------- + + def channel_last_update : Time? + request = <<-SQL + SELECT updated FROM channels + ORDER BY updated DESC + LIMIT 1 + SQL + + PG_DB.query_one?(request, as: Time) + end +end diff --git a/src/invidious/jobs/statistics_refresh_job.cr b/src/invidious/jobs/statistics_refresh_job.cr index 6569c0a1..a113bd77 100644 --- a/src/invidious/jobs/statistics_refresh_job.cr +++ b/src/invidious/jobs/statistics_refresh_job.cr @@ -47,12 +47,14 @@ class Invidious::Jobs::StatisticsRefreshJob < Invidious::Jobs::BaseJob private def refresh_stats users = STATISTICS.dig("usage", "users").as(Hash(String, Int64)) - users["total"] = db.query_one("SELECT count(*) FROM users", as: Int64) - users["activeHalfyear"] = db.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '6 months'", as: Int64) - users["activeMonth"] = db.query_one("SELECT count(*) FROM users WHERE CURRENT_TIMESTAMP - updated < '1 month'", as: Int64) + + users["total"] = Invidious::Database::Statistics.count_users_total + users["activeHalfyear"] = Invidious::Database::Statistics.count_users_active_1m + users["activeMonth"] = Invidious::Database::Statistics.count_users_active_6m + STATISTICS["metadata"] = { "updatedAt" => Time.utc.to_unix, - "lastChannelRefreshedAt" => db.query_one?("SELECT updated FROM channels ORDER BY updated DESC LIMIT 1", as: Time).try &.to_unix || 0_i64, + "lastChannelRefreshedAt" => Invidious::Database::Statistics.channel_last_update.try &.to_unix || 0_i64, } end end -- cgit v1.2.3 From c021b93b5c7d38504b9cf40307d89b81241adfd9 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 2 Dec 2021 19:16:41 +0100 Subject: Move DB queries related to channels in a separate module --- src/invidious.cr | 8 +- src/invidious/channels/channels.cr | 23 +--- src/invidious/database/channels.cr | 149 ++++++++++++++++++++++++++ src/invidious/helpers/helpers.cr | 5 +- src/invidious/jobs/pull_popular_videos_job.cr | 9 +- src/invidious/jobs/refresh_channels_job.cr | 4 +- src/invidious/routes/api/v1/authenticated.cr | 8 +- src/invidious/routes/feeds.cr | 5 +- src/invidious/users.cr | 5 +- 9 files changed, 164 insertions(+), 52 deletions(-) create mode 100644 src/invidious/database/channels.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 93b3357a..97809160 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -649,13 +649,7 @@ get "/subscription_manager" do |env| format = env.params.query["format"]? format ||= "rss" - if user.subscriptions.empty? - values = "'{}'" - else - values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}" - end - - subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) + subscriptions = Invidious::Database::Channels.select(user.subscriptions) subscriptions.sort_by!(&.author.downcase) if action_takeout diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 827b6534..5d962ab4 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -152,21 +152,14 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma end def get_channel(id, db, refresh = true, pull_all_videos = true) - if channel = db.query_one?("SELECT * FROM channels WHERE id = $1", id, as: InvidiousChannel) + if channel = Invidious::Database::Channels.select(id) if refresh && Time.utc - channel.updated > 10.minutes channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) - channel_array = channel.to_a - args = arg_array(channel_array) - - db.exec("INSERT INTO channels VALUES (#{args}) \ - ON CONFLICT (id) DO UPDATE SET author = $2, updated = $3", args: channel_array) + Invidious::Database::Channels.insert(channel, update_on_conflict: true) end else channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) - channel_array = channel.to_a - args = arg_array(channel_array) - - db.exec("INSERT INTO channels VALUES (#{args})", args: channel_array) + Invidious::Database::Channels.insert(channel) end return channel @@ -241,10 +234,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) # We don't include the 'premiere_timestamp' here because channel pages don't include them, # meaning the above timestamp is always null - was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ - updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) + was_insert = Invidious::Database::ChannelVideos.insert(video) if was_insert LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") @@ -284,10 +274,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) # We are notified of Red videos elsewhere (PubSub), which includes a correct published date, # so since they don't provide a published date here we can safely ignore them. if Time.utc - video.published > 1.minute - was_insert = db.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) \ - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, \ - updated = $4, ucid = $5, author = $6, length_seconds = $7, \ - live_now = $8, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) + was_insert = Invidious::Database::ChannelVideos.insert(video) db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert diff --git a/src/invidious/database/channels.cr b/src/invidious/database/channels.cr new file mode 100644 index 00000000..134cf59d --- /dev/null +++ b/src/invidious/database/channels.cr @@ -0,0 +1,149 @@ +require "./base.cr" + +# +# This module contains functions related to the "channels" table. +# +module Invidious::Database::Channels + extend self + + # ------------------- + # Insert / delete + # ------------------- + + def insert(channel : InvidiousChannel, update_on_conflict : Bool = false) + channel_array = channel.to_a + + request = <<-SQL + INSERT INTO channels + VALUES (#{arg_array(channel_array)}) + SQL + + if update_on_conflict + request += <<-SQL + ON CONFLICT (id) DO UPDATE + SET author = $2, updated = $3 + SQL + end + + PG_DB.exec(request, args: channel_array) + end + + # ------------------- + # Update + # ------------------- + + def update_author(id : String, author : String) + request = <<-SQL + UPDATE channels + SET updated = $1, author = $2, deleted = false + WHERE id = $3 + SQL + + PG_DB.exec(request, Time.utc, author, id) + end + + def update_mark_deleted(id : String) + request = <<-SQL + UPDATE channels + SET updated = $1, deleted = true + WHERE id = $2 + SQL + + PG_DB.exec(request, Time.utc, id) + end + + # ------------------- + # Select + # ------------------- + + def select(id : String) : InvidiousChannel? + request = <<-SQL + SELECT * FROM channels + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: InvidiousChannel) + end + + def select(ids : Array(String)) : Array(InvidiousChannel)? + return [] of InvidiousChannel if ids.empty? + values = ids.map { |id| %(('#{id}')) }.join(",") + + request = <<-SQL + SELECT * FROM channels + WHERE id = ANY(VALUES #{values}) + SQL + + return PG_DB.query_all(request, as: InvidiousChannel) + end +end + +# +# This module contains functions related to the "channel_videos" table. +# +module Invidious::Database::ChannelVideos + extend self + + # ------------------- + # Insert + # ------------------- + + # This function returns the status of the query (i.e: success?) + def insert(video : ChannelVideo, with_premiere_timestamp : Bool = false) : Bool + if with_premiere_timestamp + last_items = "premiere_timestamp = $9, views = $10" + else + last_items = "views = $10" + end + + request = <<-SQL + INSERT INTO channel_videos + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT (id) DO UPDATE + SET title = $2, published = $3, updated = $4, ucid = $5, + author = $6, length_seconds = $7, live_now = $8, #{last_items} + RETURNING (xmax=0) AS was_insert + SQL + + return PG_DB.query_one(request, *video.to_tuple, as: Bool) + end + + # ------------------- + # Select + # ------------------- + + def select(ids : Array(String)) : Array(ChannelVideo) + return [] of ChannelVideo if ids.empty? + + request = <<-SQL + SELECT * FROM channel_videos + WHERE id IN (#{arg_array(ids)}) + ORDER BY published DESC + SQL + + return PG_DB.query_all(request, args: ids, as: ChannelVideo) + end + + def select_notfications(ucid : String, since : Time) : Array(ChannelVideo) + request = <<-SQL + SELECT * FROM channel_videos + WHERE ucid = $1 AND published > $2 + ORDER BY published DESC + LIMIT 15 + SQL + + return PG_DB.query_all(request, ucid, since, as: ChannelVideo) + end + + def select_popular_videos : Array(ChannelVideo) + request = <<-SQL + SELECT DISTINCT ON (ucid) * + FROM channel_videos + WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d + GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40) + ORDER BY ucid, published DESC + SQL + + PG_DB.query_all(request, as: ChannelVideo) + end +end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 96a78eb9..014c04a8 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -235,11 +235,12 @@ def create_notification_stream(env, topics, connection_channel) spawn do begin if since + since_unix = Time.unix(since.not_nil!) + topics.try &.each do |topic| case topic when .match(/UC[A-Za-z0-9_-]{22}/) - PG_DB.query_all("SELECT * FROM channel_videos WHERE ucid = $1 AND published > $2 ORDER BY published DESC LIMIT 15", - topic, Time.unix(since.not_nil!), as: ChannelVideo).each do |video| + Invidious::Database::ChannelVideos.select_notfications(topic, since_unix).each do |video| response = JSON.parse(video.to_json(locale)) if fields_text = env.params.query["fields"]? diff --git a/src/invidious/jobs/pull_popular_videos_job.cr b/src/invidious/jobs/pull_popular_videos_job.cr index 38de816e..dc785bae 100644 --- a/src/invidious/jobs/pull_popular_videos_job.cr +++ b/src/invidious/jobs/pull_popular_videos_job.cr @@ -1,11 +1,4 @@ class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob - QUERY = <<-SQL - SELECT DISTINCT ON (ucid) * - FROM channel_videos - WHERE ucid IN (SELECT channel FROM (SELECT UNNEST(subscriptions) AS channel FROM users) AS d - GROUP BY channel ORDER BY COUNT(channel) DESC LIMIT 40) - ORDER BY ucid, published DESC - SQL POPULAR_VIDEOS = Atomic.new([] of ChannelVideo) private getter db : DB::Database @@ -14,7 +7,7 @@ class Invidious::Jobs::PullPopularVideosJob < Invidious::Jobs::BaseJob def begin loop do - videos = db.query_all(QUERY, as: ChannelVideo) + videos = Invidious::Database::ChannelVideos.select_popular_videos .sort_by!(&.published) .reverse! diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 2321e964..c224c745 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -35,11 +35,11 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob lim_fibers = max_fibers LOGGER.trace("RefreshChannelsJob: #{id} fiber : Updating DB") - db.exec("UPDATE channels SET updated = $1, author = $2, deleted = false WHERE id = $3", Time.utc, channel.author, id) + Invidious::Database::Channels.update_author(id, channel.author) rescue ex LOGGER.error("RefreshChannelsJob: #{id} : #{ex.message}") if ex.message == "Deleted or invalid channel" - db.exec("UPDATE channels SET updated = $1, deleted = true WHERE id = $2", Time.utc, id) + Invidious::Database::Channels.update_mark_deleted(id) else lim_fibers = 1 LOGGER.error("RefreshChannelsJob: #{id} fiber : backing off for #{backoff}s") diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index d74dca5c..a3ac2add 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -72,13 +72,7 @@ module Invidious::Routes::API::V1::Authenticated env.response.content_type = "application/json" user = env.get("user").as(User) - if user.subscriptions.empty? - values = "'{}'" - else - values = "VALUES #{user.subscriptions.map { |id| %(('#{id}')) }.join(",")}" - end - - subscriptions = PG_DB.query_all("SELECT * FROM channels WHERE id = ANY(#{values})", as: InvidiousChannel) + subscriptions = Invidious::Database::Channels.select(user.subscriptions) JSON.build do |json| json.array do diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 6424ab47..78e6bd40 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -416,10 +416,7 @@ module Invidious::Routes::Feeds views: video.views, }) - was_insert = PG_DB.query_one("INSERT INTO channel_videos VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) - ON CONFLICT (id) DO UPDATE SET title = $2, published = $3, - updated = $4, ucid = $5, author = $6, length_seconds = $7, - live_now = $8, premiere_timestamp = $9, views = $10 returning (xmax=0) as was_insert", *video.to_tuple, as: Bool) + was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) PG_DB.exec("UPDATE users SET notifications = array_append(notifications, $1), feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 584082be..92143437 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -242,10 +242,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) if user.preferences.notifications_only && !notifications.empty? # Only show notifications - - args = arg_array(notifications) - - notifications = db.query_all("SELECT * FROM channel_videos WHERE id IN (#{args}) ORDER BY published DESC", args: notifications, as: ChannelVideo) + notifications = Invidious::Database::ChannelVideos.select(notifications) videos = [] of ChannelVideo notifications.sort_by!(&.published).reverse! -- cgit v1.2.3 From 92eea3b18b406e7eb86e1bd95dfaf9078f49ed72 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 2 Dec 2021 23:57:13 +0100 Subject: Move DB queries related to session tokens in a separate module --- src/invidious.cr | 10 ++-- src/invidious/database/nonces.cr | 46 +++++++++++++++++ src/invidious/database/sessions.cr | 74 ++++++++++++++++++++++++++++ src/invidious/helpers/handlers.cr | 4 +- src/invidious/helpers/tokens.cr | 8 +-- src/invidious/routes/api/v1/authenticated.cr | 6 +-- src/invidious/routes/login.cr | 6 +-- src/invidious/users.cr | 8 ++- 8 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 src/invidious/database/nonces.cr create mode 100644 src/invidious/database/sessions.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 97809160..94620a26 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -247,7 +247,7 @@ before_all do |env| # Invidious users only have SID if !env.request.cookies.has_key? "SSID" - if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) + if email = Invidious::Database::SessionIDs.select_email(sid) user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) csrf_token = generate_response(sid, { ":authorize_token", @@ -633,6 +633,7 @@ get "/subscription_manager" do |env| end user = user.as(User) + sid = sid.as(String) if !user.password # Refresh account @@ -1008,7 +1009,7 @@ post "/delete_account" do |env| view_name = "subscriptions_#{sha256(user.email)}" PG_DB.exec("DELETE FROM users * WHERE email = $1", user.email) - PG_DB.exec("DELETE FROM session_ids * WHERE email = $1", user.email) + Invidious::Database::SessionIDs.delete(email: user.email) PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}") env.request.cookies.each do |cookie| @@ -1150,8 +1151,7 @@ get "/token_manager" do |env| end user = user.as(User) - - tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1 ORDER BY issued DESC", user.email, as: {session: String, issued: Time}) + tokens = Invidious::Database::SessionIDs.select_all(user.email) templated "token_manager" end @@ -1200,7 +1200,7 @@ post "/token_ajax" do |env| case action when .starts_with? "action_revoke_token" - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1 AND email = $2", session, user.email) + Invidious::Database::SessionIDs.delete(sid: session, email: user.email) else next error_json(400, "Unsupported action #{action}") end diff --git a/src/invidious/database/nonces.cr b/src/invidious/database/nonces.cr new file mode 100644 index 00000000..469fcbd8 --- /dev/null +++ b/src/invidious/database/nonces.cr @@ -0,0 +1,46 @@ +require "./base.cr" + +module Invidious::Database::Nonces + extend self + + # ------------------- + # Insert + # ------------------- + + def insert(nonce : String, expire : Time) + request = <<-SQL + INSERT INTO nonces + VALUES ($1, $2) + ON CONFLICT DO NOTHING + SQL + + PG_DB.exec(request, nonce, expire) + end + + # ------------------- + # Update + # ------------------- + + def update_set_expired(nonce : String) + request = <<-SQL + UPDATE nonces + SET expire = $1 + WHERE nonce = $2 + SQL + + PG_DB.exec(request, Time.utc(1990, 1, 1), nonce) + end + + # ------------------- + # Select + # ------------------- + + def select(nonce : String) : Tuple(String, Time)? + request = <<-SQL + SELECT * FROM nonces + WHERE nonce = $1 + SQL + + return PG_DB.query_one?(request, nonce, as: {String, Time}) + end +end diff --git a/src/invidious/database/sessions.cr b/src/invidious/database/sessions.cr new file mode 100644 index 00000000..d5f85dd6 --- /dev/null +++ b/src/invidious/database/sessions.cr @@ -0,0 +1,74 @@ +require "./base.cr" + +module Invidious::Database::SessionIDs + extend self + + # ------------------- + # Insert + # ------------------- + + def insert(sid : String, email : String, handle_conflicts : Bool = false) + request = <<-SQL + INSERT INTO session_ids + VALUES ($1, $2, $3) + SQL + + request += " ON CONFLICT (id) DO NOTHING" if handle_conflicts + + PG_DB.exec(request, sid, email, Time.utc) + end + + # ------------------- + # Delete + # ------------------- + + def delete(*, sid : String) + request = <<-SQL + DELETE FROM session_ids * + WHERE id = $1 + SQL + + PG_DB.exec(request, sid) + end + + def delete(*, email : String) + request = <<-SQL + DELETE FROM session_ids * + WHERE email = $1 + SQL + + PG_DB.exec(request, email) + end + + def delete(*, sid : String, email : String) + request = <<-SQL + DELETE FROM session_ids * + WHERE id = $1 AND email = $2 + SQL + + PG_DB.exec(request, sid, email) + end + + # ------------------- + # Select + # ------------------- + + def select_email(sid : String) : String? + request = <<-SQL + SELECT email FROM session_ids + WHERE id = $1 + SQL + + PG_DB.query_one?(request, sid, as: String) + end + + def select_all(email : String) : Array({session: String, issued: Time}) + request = <<-SQL + SELECT id, issued FROM session_ids + WHERE email = $1 + ORDER BY issued DESC + SQL + + PG_DB.query_all(request, email, as: {session: String, issued: Time}) + end +end diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 045b6701..0aa86e64 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -99,7 +99,7 @@ class AuthHandler < Kemal::Handler session = URI.decode_www_form(token["session"].as_s) scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, PG_DB, nil) - if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", session, as: String) + if email = Invidious::Database::SessionIDs.select_email(session) user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) end elsif sid = env.request.cookies["SID"]?.try &.value @@ -107,7 +107,7 @@ class AuthHandler < Kemal::Handler raise "Cannot use token as SID" end - if email = PG_DB.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) + if email = Invidious::Database::SessionIDs.select_email(sid) user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) end diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index 3874799a..91405822 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -2,7 +2,7 @@ require "crypto/subtle" def generate_token(email, scopes, expire, key, db) session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}" - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", session, email, Time.utc) + Invidious::Database::SessionIDs.insert(session, email) token = { "session" => session, @@ -30,7 +30,7 @@ def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = fa if use_nonce nonce = Random::Secure.hex(16) - db.exec("INSERT INTO nonces VALUES ($1, $2) ON CONFLICT DO NOTHING", nonce, expire) + Invidious::Database::Nonces.insert(nonce, expire) token["nonce"] = nonce end @@ -92,9 +92,9 @@ def validate_request(token, session, request, key, db, locale = nil) raise InfoException.new("Invalid signature") end - if token["nonce"]? && (nonce = db.query_one?("SELECT * FROM nonces WHERE nonce = $1", token["nonce"], as: {String, Time})) + if token["nonce"]? && (nonce = Invidious::Database::Nonces.select(token["nonce"].as_s)) if nonce[1] > Time.utc - db.exec("UPDATE nonces SET expire = $1 WHERE nonce = $2", Time.utc(1990, 1, 1), nonce[0]) + Invidious::Database::Nonces.update_set_expired(nonce[0]) else raise InfoException.new("Erroneous token") end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index a3ac2add..c95007c2 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -312,7 +312,7 @@ module Invidious::Routes::API::V1::Authenticated user = env.get("user").as(User) scopes = env.get("scopes").as(Array(String)) - tokens = PG_DB.query_all("SELECT id, issued FROM session_ids WHERE email = $1", user.email, as: {session: String, issued: Time}) + tokens = Invidious::Database::SessionIDs.select_all(user.email) JSON.build do |json| json.array do @@ -400,9 +400,9 @@ module Invidious::Routes::API::V1::Authenticated # Allow tokens to revoke other tokens with correct scope if session == env.get("session").as(String) - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) + Invidious::Database::SessionIDs.delete(sid: session) elsif scopes_include_scope(scopes, "GET:tokens") - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", session) + Invidious::Database::SessionIDs.delete(sid: session) else return error_json(400, "Cannot revoke session #{session}") end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 2a50561d..e70206cc 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -336,7 +336,7 @@ module Invidious::Routes::Login if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) + Invidious::Database::SessionIDs.insert(sid, email) if Kemal.config.ssl || CONFIG.https_only secure = true @@ -455,7 +455,7 @@ module Invidious::Routes::Login args = arg_array(user_array) PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array) - PG_DB.exec("INSERT INTO session_ids VALUES ($1, $2, $3)", sid, email, Time.utc) + Invidious::Database::SessionIDs.insert(sid, email) view_name = "subscriptions_#{sha256(user.email)}" PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") @@ -511,7 +511,7 @@ module Invidious::Routes::Login return error_template(400, ex) end - PG_DB.exec("DELETE FROM session_ids * WHERE id = $1", sid) + Invidious::Database::SessionIDs.delete(sid: sid) env.request.cookies.each do |cookie| cookie.expires = Time.utc(1990, 1, 1) diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 92143437..3e9a9e68 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -30,7 +30,7 @@ struct User end def get_user(sid, headers, db, refresh = true) - if email = db.query_one?("SELECT email FROM session_ids WHERE id = $1", sid, as: String) + if email = Invidious::Database::SessionIDs.select_email(sid) user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User) if refresh && Time.utc - user.updated > 1.minute @@ -42,8 +42,7 @@ def get_user(sid, headers, db, refresh = true) db.exec("INSERT INTO users VALUES (#{args}) \ ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array) - db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ - ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc) + Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) begin view_name = "subscriptions_#{sha256(user.email)}" @@ -60,8 +59,7 @@ def get_user(sid, headers, db, refresh = true) db.exec("INSERT INTO users VALUES (#{args}) \ ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array) - db.exec("INSERT INTO session_ids VALUES ($1,$2,$3) \ - ON CONFLICT (id) DO NOTHING", sid, user.email, Time.utc) + Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) begin view_name = "subscriptions_#{sha256(user.email)}" -- cgit v1.2.3 From 094f83564297257a956d2e42d3b70adfa78b3185 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 3 Dec 2021 02:27:51 +0100 Subject: Move DB queries related to 'users' in a separate module (1/2) --- src/invidious.cr | 15 ++-- src/invidious/database/users.cr | 129 +++++++++++++++++++++++++++ src/invidious/helpers/handlers.cr | 4 +- src/invidious/routes/api/v1/authenticated.cr | 4 +- src/invidious/routes/feeds.cr | 2 +- src/invidious/routes/login.cr | 9 +- src/invidious/routes/watch.cr | 2 +- src/invidious/users.cr | 16 +--- 8 files changed, 147 insertions(+), 34 deletions(-) create mode 100644 src/invidious/database/users.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 94620a26..91f19d69 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -248,7 +248,7 @@ before_all do |env| # Invidious users only have SID if !env.request.cookies.has_key? "SSID" if email = Invidious::Database::SessionIDs.select_email(sid) - user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + user = Invidious::Database::Users.select!(email: email) csrf_token = generate_response(sid, { ":authorize_token", ":playlist_ajax", @@ -458,10 +458,10 @@ post "/watch_ajax" do |env| case action when "action_mark_watched" if !user.watched.includes? id - PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.email) + Invidious::Database::Users.mark_watched(user, id) end when "action_mark_unwatched" - PG_DB.exec("UPDATE users SET watched = array_remove(watched, $1) WHERE email = $2", id, user.email) + Invidious::Database::Users.mark_unwatched(user, id) else next error_json(400, "Unsupported action #{action}") end @@ -599,16 +599,15 @@ post "/subscription_ajax" do |env| # Sync subscriptions with YouTube subscribe_ajax(channel_id, action, env.request.headers) end - email = user.email case action when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id get_channel(channel_id, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions, $1) WHERE email = $2", channel_id, email) + Invidious::Database::Users.subscribe_channel(user, channel_id) end when "action_remove_subscriptions" - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", channel_id, email) + Invidious::Database::Users.unsubscribe_channel(user, channel_id) else next error_json(400, "Unsupported action #{action}") end @@ -1008,7 +1007,7 @@ post "/delete_account" do |env| end view_name = "subscriptions_#{sha256(user.email)}" - PG_DB.exec("DELETE FROM users * WHERE email = $1", user.email) + Invidious::Database::Users.delete(user) Invidious::Database::SessionIDs.delete(email: user.email) PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}") @@ -1059,7 +1058,7 @@ post "/clear_watch_history" do |env| next error_template(400, ex) end - PG_DB.exec("UPDATE users SET watched = '{}' WHERE email = $1", user.email) + Invidious::Database::Users.clear_watch_history(user) env.redirect referer end diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr new file mode 100644 index 00000000..aa3b9f85 --- /dev/null +++ b/src/invidious/database/users.cr @@ -0,0 +1,129 @@ +require "./base.cr" + +module Invidious::Database::Users + extend self + + # ------------------- + # Insert / delete + # ------------------- + + def insert(user : User, update_on_conflict : Bool = false) + user_array = user.to_a + user_array[4] = user_array[4].to_json # User preferences + + request = <<-SQL + INSERT INTO users + VALUES (#{arg_array(user_array)}) + SQL + + if update_on_conflict + request += <<-SQL + ON CONFLICT (email) DO UPDATE + SET updated = $1, subscriptions = $3 + SQL + end + + PG_DB.exec(request, args: user_array) + end + + def delete(user : User) + request = <<-SQL + DELETE FROM users * + WHERE email = $1 + SQL + + PG_DB.exec(request, user.email) + end + + # ------------------- + # Update (history) + # ------------------- + + def mark_watched(user : User, vid : String) + request = <<-SQL + UPDATE users + SET watched = array_append(watched, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, vid, user.email) + end + + def mark_unwatched(user : User, vid : String) + request = <<-SQL + UPDATE users + SET watched = array_remove(watched, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, vid, user.email) + end + + def clear_watch_history(user : User) + request = <<-SQL + UPDATE users + SET watched = '{}' + WHERE email = $1 + SQL + + PG_DB.exec(request, user.email) + end + + # ------------------- + # Update (channels) + # ------------------- + + def subscribe_channel(user : User, ucid : String) + request = <<-SQL + UPDATE users + SET feed_needs_update = true, + subscriptions = array_append(subscriptions,$1) + WHERE email = $2 + SQL + + PG_DB.exec(request, ucid, user.email) + end + + def unsubscribe_channel(user : User, ucid : String) + request = <<-SQL + UPDATE users + SET feed_needs_update = true, + subscriptions = array_remove(subscriptions, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, ucid, user.email) + end + + # ------------------- + # Select + # ------------------- + + def select(*, email : String) : User? + request = <<-SQL + SELECT * FROM users + WHERE email = $1 + SQL + + return PG_DB.query_one?(request, email, as: User) + end + + # Same as select, but can raise an exception + def select!(*, email : String) : User + request = <<-SQL + SELECT * FROM users + WHERE email = $1 + SQL + + return PG_DB.query_one(request, email, as: User) + end + + def select(*, token : String) : User? + request = <<-SQL + SELECT * FROM users + WHERE token = $1 + SQL + + return PG_DB.query_one?(request, token, as: User) + end +end diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 0aa86e64..d52035c7 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -100,7 +100,7 @@ class AuthHandler < Kemal::Handler scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, PG_DB, nil) if email = Invidious::Database::SessionIDs.select_email(session) - user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + user = Invidious::Database::Users.select!(email: email) end elsif sid = env.request.cookies["SID"]?.try &.value if sid.starts_with? "v1:" @@ -108,7 +108,7 @@ class AuthHandler < Kemal::Handler end if email = Invidious::Database::SessionIDs.select_email(sid) - user = PG_DB.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + user = Invidious::Database::Users.select!(email: email) end scopes = [":*"] diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index c95007c2..d9b58ebf 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -94,7 +94,7 @@ module Invidious::Routes::API::V1::Authenticated if !user.subscriptions.includes? ucid get_channel(ucid, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_append(subscriptions,$1) WHERE email = $2", ucid, user.email) + Invidious::Database::Users.subscribe_channel(user, ucid) end # For Google accounts, access tokens don't have enough information to @@ -110,7 +110,7 @@ module Invidious::Routes::API::V1::Authenticated ucid = env.params.url["ucid"] - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = array_remove(subscriptions, $1) WHERE email = $2", ucid, user.email) + Invidious::Database::Users.unsubscribe_channel(user, ucid) env.response.status_code = 204 end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 78e6bd40..4e7ec9ad 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -220,7 +220,7 @@ module Invidious::Routes::Feeds haltf env, status_code: 403 end - user = PG_DB.query_one?("SELECT * FROM users WHERE token = $1", token.strip, as: User) + user = Invidious::Database::Users.select(token: token.strip) if !user haltf env, status_code: 403 end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index e70206cc..8f703464 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -327,7 +327,7 @@ module Invidious::Routes::Login return error_template(401, "Password is a required field") end - user = PG_DB.query_one?("SELECT * FROM users WHERE email = $1", email, as: User) + user = Invidious::Database::Users.select(email: email) if user if !user.password @@ -449,12 +449,7 @@ module Invidious::Routes::Login end end - user_array = user.to_a - user_array[4] = user_array[4].to_json # User preferences - - args = arg_array(user_array) - - PG_DB.exec("INSERT INTO users VALUES (#{args})", args: user_array) + Invidious::Database::Users.insert(user) Invidious::Database::SessionIDs.insert(sid, email) view_name = "subscriptions_#{sha256(user.email)}" diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index b24222ff..c1ec0bc6 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -76,7 +76,7 @@ module Invidious::Routes::Watch env.params.query.delete_all("iv_load_policy") if watched && !watched.includes? id - PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email) + Invidious::Database::Users.mark_watched(user.as(User), id) end if notifications && notifications.includes? id diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 3e9a9e68..933c451d 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -31,17 +31,12 @@ end def get_user(sid, headers, db, refresh = true) if email = Invidious::Database::SessionIDs.select_email(sid) - user = db.query_one("SELECT * FROM users WHERE email = $1", email, as: User) + user = Invidious::Database::Users.select!(email: email) if refresh && Time.utc - user.updated > 1.minute user, sid = fetch_user(sid, headers, db) - user_array = user.to_a - user_array[4] = user_array[4].to_json # User preferences - args = arg_array(user_array) - - db.exec("INSERT INTO users VALUES (#{args}) \ - ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array) + Invidious::Database::Users.insert(user, update_on_conflict: true) Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) begin @@ -52,13 +47,8 @@ def get_user(sid, headers, db, refresh = true) end else user, sid = fetch_user(sid, headers, db) - user_array = user.to_a - user_array[4] = user_array[4].to_json # User preferences - args = arg_array(user.to_a) - - db.exec("INSERT INTO users VALUES (#{args}) \ - ON CONFLICT (email) DO UPDATE SET updated = $1, subscriptions = $3", args: user_array) + Invidious::Database::Users.insert(user, update_on_conflict: true) Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) begin -- cgit v1.2.3 From 7691f5352025d7b2158ebae73417dd8619baea32 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 3 Dec 2021 03:29:52 +0100 Subject: Move DB queries related to 'users' in a separate module (2/2) --- src/invidious.cr | 18 +++--- src/invidious/channels/channels.cr | 7 +-- src/invidious/database/users.cr | 89 ++++++++++++++++++++++++++++ src/invidious/routes/api/v1/authenticated.cr | 5 +- src/invidious/routes/embed.cr | 2 +- src/invidious/routes/feeds.cr | 7 +-- src/invidious/routes/login.cr | 8 +-- src/invidious/routes/preferences.cr | 20 +++---- src/invidious/routes/watch.cr | 2 +- src/invidious/users.cr | 6 +- 10 files changed, 121 insertions(+), 43 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 91f19d69..0149be11 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -759,18 +759,18 @@ post "/data_control" do |env| user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) + Invidious::Database::Users.update_subscriptions(user) end if body["watch_history"]? user.watched += body["watch_history"].as_a.map(&.as_s) user.watched.uniq! - PG_DB.exec("UPDATE users SET watched = $1 WHERE email = $2", user.watched, user.email) + Invidious::Database::Users.update_watch_history(user) end if body["preferences"]? user.preferences = Preferences.from_json(body["preferences"].to_json) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", user.preferences.to_json, user.email) + Invidious::Database::Users.update_preferences(user) end if playlists = body["playlists"]?.try &.as_a? @@ -831,7 +831,7 @@ post "/data_control" do |env| user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) + Invidious::Database::Users.update_subscriptions(user) when "import_freetube" user.subscriptions += body.scan(/"channelId":"(?[a-zA-Z0-9_-]{24})"/).map do |md| md["channel_id"] @@ -840,7 +840,7 @@ post "/data_control" do |env| user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) + Invidious::Database::Users.update_subscriptions(user) when "import_newpipe_subscriptions" body = JSON.parse(body) user.subscriptions += body["subscriptions"].as_a.compact_map do |channel| @@ -859,7 +859,7 @@ post "/data_control" do |env| user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) + Invidious::Database::Users.update_subscriptions(user) when "import_newpipe" Compress::Zip::Reader.open(IO::Memory.new(body)) do |file| file.each_entry do |entry| @@ -871,14 +871,14 @@ post "/data_control" do |env| user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v=")) user.watched.uniq! - PG_DB.exec("UPDATE users SET watched = $1 WHERE email = $2", user.watched, user.email) + Invidious::Database::Users.update_watch_history(user) user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/")) user.subscriptions.uniq! user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) - PG_DB.exec("UPDATE users SET feed_needs_update = true, subscriptions = $1 WHERE email = $2", user.subscriptions, user.email) + Invidious::Database::Users.update_subscriptions(user) db.close tempfile.delete @@ -962,7 +962,7 @@ post "/change_password" do |env| end new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10) - PG_DB.exec("UPDATE users SET password = $1 WHERE email = $2", new_password.to_s, user.email) + Invidious::Database::Users.update_password(user, new_password.to_s) env.redirect referer end diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 5d962ab4..2ec510f0 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -238,8 +238,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) if was_insert LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) + Invidious::Database::Users.add_notification(video) else LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") end @@ -275,9 +274,7 @@ def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) # so since they don't provide a published date here we can safely ignore them. if Time.utc - video.published > 1.minute was_insert = Invidious::Database::ChannelVideos.insert(video) - - db.exec("UPDATE users SET notifications = array_append(notifications, $1), \ - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert + Invidious::Database::Users.add_notification(video) if was_insert end end diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr index aa3b9f85..71650918 100644 --- a/src/invidious/database/users.cr +++ b/src/invidious/database/users.cr @@ -39,6 +39,16 @@ module Invidious::Database::Users # Update (history) # ------------------- + def update_watch_history(user : User) + request = <<-SQL + UPDATE users + SET watched = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.watched, user.email) + end + def mark_watched(user : User, vid : String) request = <<-SQL UPDATE users @@ -73,6 +83,16 @@ module Invidious::Database::Users # Update (channels) # ------------------- + def update_subscriptions(user : User) + request = <<-SQL + UPDATE users + SET feed_needs_update = true, subscriptions = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.subscriptions, user.email) + end + def subscribe_channel(user : User, ucid : String) request = <<-SQL UPDATE users @@ -95,6 +115,65 @@ module Invidious::Database::Users PG_DB.exec(request, ucid, user.email) end + # ------------------- + # Update (notifs) + # ------------------- + + def add_notification(video : ChannelVideo) + request = <<-SQL + UPDATE users + SET notifications = array_append(notifications, $1), + feed_needs_update = true + WHERE $2 = ANY(subscriptions) + SQL + + PG_DB.exec(request, video.id, video.ucid) + end + + def remove_notification(user : User, vid : String) + request = <<-SQL + UPDATE users + SET notifications = array_remove(notifications, $1) + WHERE email = $2 + SQL + + PG_DB.exec(request, vid, user.email) + end + + def clear_notifications(user : User) + request = <<-SQL + UPDATE users + SET notifications = $1, updated = $2 + WHERE email = $3 + SQL + + PG_DB.exec(request, [] of String, Time.utc, user) + end + + # ------------------- + # Update (misc) + # ------------------- + + def update_preferences(user : User) + request = <<-SQL + UPDATE users + SET preferences = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.preferences.to_json, user.email) + end + + def update_password(user : User, pass : String) + request = <<-SQL + UPDATE users + SET password = $1 + WHERE email = $2 + SQL + + PG_DB.exec(request, user.email, pass) + end + # ------------------- # Select # ------------------- @@ -126,4 +205,14 @@ module Invidious::Database::Users return PG_DB.query_one?(request, token, as: User) end + + def select_notifications(user : User) : Array(String) + request = <<-SQL + SELECT notifications + FROM users + WHERE email = $1 + SQL + + return PG_DB.query_one(request, user.email, as: Array(String)) + end end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index d9b58ebf..62b09f79 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -22,12 +22,11 @@ module Invidious::Routes::API::V1::Authenticated user = env.get("user").as(User) begin - preferences = Preferences.from_json(env.request.body || "{}") + user.preferences = Preferences.from_json(env.request.body || "{}") rescue - preferences = user.preferences end - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) + Invidious::Database::Users.update_preferences(user) env.response.status_code = 204 end diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 049ee344..2c648b5a 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -137,7 +137,7 @@ module Invidious::Routes::Embed # end if notifications && notifications.includes? id - PG_DB.exec("UPDATE users SET notifications = array_remove(notifications, $1) WHERE email = $2", id, user.as(User).email) + Invidious::Database::Users.remove_notification(user.as(User), id) env.get("user").as(User).notifications.delete(id) notifications.delete(id) end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 4e7ec9ad..be58dd8d 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -99,8 +99,7 @@ module Invidious::Routes::Feeds # we know a user has looked at their feed e.g. in the past 10 minutes, # they've already seen a video posted 20 minutes ago, and don't need # to be notified. - PG_DB.exec("UPDATE users SET notifications = $1, updated = $2 WHERE email = $3", [] of String, Time.utc, - user.email) + Invidious::Database::Users.clear_notifications(user) user.notifications = [] of String env.set "user", user @@ -417,9 +416,7 @@ module Invidious::Routes::Feeds }) was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) - - PG_DB.exec("UPDATE users SET notifications = array_append(notifications, $1), - feed_needs_update = true WHERE $2 = ANY(subscriptions)", video.id, video.ucid) if was_insert + Invidious::Database::Users.add_notification(video) if was_insert end end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 8f703464..c94fd09b 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -303,8 +303,8 @@ module Invidious::Routes::Login end if env.request.cookies["PREFS"]? - preferences = env.get("preferences").as(Preferences) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) + user.preferences = env.get("preferences").as(Preferences) + Invidious::Database::Users.update_preferences(user) cookie = env.request.cookies["PREFS"] cookie.expires = Time.utc(1990, 1, 1) @@ -470,8 +470,8 @@ module Invidious::Routes::Login end if env.request.cookies["PREFS"]? - preferences = env.get("preferences").as(Preferences) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences.to_json, user.email) + user.preferences = env.get("preferences").as(Preferences) + Invidious::Database::Users.update_preferences(user) cookie = env.request.cookies["PREFS"] cookie.expires = Time.utc(1990, 1, 1) diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 15c00700..a832076c 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -170,11 +170,12 @@ module Invidious::Routes::PreferencesRoute vr_mode: vr_mode, show_nick: show_nick, save_player_pos: save_player_pos, - }.to_json).to_json + }.to_json) if user = env.get? "user" user = user.as(User) - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) + user.preferences = preferences + Invidious::Database::Users.update_preferences(user) if CONFIG.admins.includes? user.email CONFIG.default_user_preferences.default_home = env.params.body["admin_default_home"]?.try &.as(String) || CONFIG.default_user_preferences.default_home @@ -220,10 +221,10 @@ module Invidious::Routes::PreferencesRoute end if CONFIG.domain - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years, + env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, secure: secure, http_only: true) else - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years, + env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, secure: secure, http_only: true) end end @@ -241,18 +242,15 @@ module Invidious::Routes::PreferencesRoute if user = env.get? "user" user = user.as(User) - preferences = user.preferences - case preferences.dark_mode + case user.preferences.dark_mode when "dark" - preferences.dark_mode = "light" + user.preferences.dark_mode = "light" else - preferences.dark_mode = "dark" + user.preferences.dark_mode = "dark" end - preferences = preferences.to_json - - PG_DB.exec("UPDATE users SET preferences = $1 WHERE email = $2", preferences, user.email) + Invidious::Database::Users.update_preferences(user) else preferences = env.get("preferences").as(Preferences) diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index c1ec0bc6..f7bd7d81 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -80,7 +80,7 @@ module Invidious::Routes::Watch end if notifications && notifications.includes? id - PG_DB.exec("UPDATE users SET notifications = array_remove(notifications, $1) WHERE email = $2", id, user.as(User).email) + Invidious::Database::Users.remove_notification(user.as(User), id) env.get("user").as(User).notifications.delete(id) notifications.delete(id) end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 933c451d..efc4dd52 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -224,8 +224,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit - notifications = db.query_one("SELECT notifications FROM users WHERE email = $1", user.email, - as: Array(String)) + notifications = Invidious::Database::Users.select_notifications(user) view_name = "subscriptions_#{sha256(user.email)}" if user.preferences.notifications_only && !notifications.empty? @@ -296,8 +295,7 @@ def get_subscription_feed(db, user, max_results = 40, page = 1) else nil # Ignore end - notifications = PG_DB.query_one("SELECT notifications FROM users WHERE email = $1", user.email, as: Array(String)) - + notifications = Invidious::Database::Users.select_notifications(user) notifications = videos.select { |v| notifications.includes? v.id } videos = videos - notifications end -- cgit v1.2.3 From 85cf27119cb230259550bfb795dffcb724ebebf3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 6 Dec 2021 17:02:15 +0100 Subject: Move DB queries related to playlists in a separate module (3/3) --- src/invidious.cr | 4 +- src/invidious/database/playlists.cr | 83 +++++++++++++++++++++++++++++++++++++ src/invidious/playlists.cr | 10 ++--- src/invidious/routes/feeds.cr | 5 ++- src/invidious/views/watch.ecr | 2 +- 5 files changed, 94 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 0149be11..561fc9cf 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -656,7 +656,7 @@ get "/subscription_manager" do |env| if format == "json" env.response.content_type = "application/json" env.response.headers["content-disposition"] = "attachment" - playlists = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + playlists = Invidious::Database::Playlists.select_like_iv(user.email) next JSON.build do |json| json.object do @@ -672,7 +672,7 @@ get "/subscription_manager" do |env| json.field "privacy", playlist.privacy.to_s json.field "videos" do json.array do - PG_DB.query_all("SELECT id FROM playlist_videos WHERE plid = $1 ORDER BY array_position($2, index) LIMIT 500", playlist.id, playlist.index, as: String).each do |video_id| + Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| json.string video_id end end diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index 1dba64f3..950d5f4b 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -117,6 +117,39 @@ module Invidious::Database::Playlists return PG_DB.query_all(request, author, as: InvidiousPlaylist) end + # ------------------- + # Salect (filtered) + # ------------------- + + def select_like_iv(email : String) : Array(InvidiousPlaylist) + request = <<-SQL + SELECT * FROM playlists + WHERE author = $1 AND id LIKE 'IV%' + ORDER BY created + SQL + + PG_DB.query_all(request, email, as: InvidiousPlaylist) + end + + def select_not_like_iv(email : String) : Array(InvidiousPlaylist) + request = <<-SQL + SELECT * FROM playlists + WHERE author = $1 AND id NOT LIKE 'IV%' + ORDER BY created + SQL + + PG_DB.query_all(request, email, as: InvidiousPlaylist) + end + + def select_user_created_playlists(email : String) : Array({String, String}) + request = <<-SQL + SELECT id,title FROM playlists + WHERE author = $1 AND id LIKE 'IV%' + SQL + + PG_DB.query_all(request, email, as: {String, String}) + end + # ------------------- # Misc checks # ------------------- @@ -148,6 +181,8 @@ end module Invidious::Database::PlaylistVideos extend self + private alias VideoIndex = Int64 | Array(Int64) + # ------------------- # Insert / Delete # ------------------- @@ -171,4 +206,52 @@ module Invidious::Database::PlaylistVideos PG_DB.exec(request, index) end + + # ------------------- + # Salect + # ------------------- + + def select(plid : String, index : VideoIndex, offset, limit = 100) : Array(PlaylistVideo) + request = <<-SQL + SELECT * FROM playlist_videos + WHERE plid = $1 + ORDER BY array_position($2, index) + LIMIT $3 + OFFSET $4 + SQL + + return PG_DB.query_all(request, plid, index, limit, offset, as: PlaylistVideo) + end + + def select_index(plid : String, vid : String) : Int64? + request = <<-SQL + SELECT index FROM playlist_videos + WHERE plid = $1 AND id = $2 + LIMIT 1 + SQL + + return PG_DB.query_one?(request, plid, vid, as: Int64) + end + + def select_one_id(plid : String, index : VideoIndex) : String? + request = <<-SQL + SELECT id FROM playlist_videos + WHERE plid = $1 + ORDER BY array_position($2, index) + LIMIT 1 + SQL + + return PG_DB.query_one?(request, plid, index, as: String) + end + + def select_ids(plid : String, index : VideoIndex, limit = 500) : Array(String) + request = <<-SQL + SELECT id FROM playlist_videos + WHERE plid = $1 + ORDER BY array_position($2, index) + LIMIT $3 + SQL + + return PG_DB.query_all(request, plid, index, limit, as: String) + end end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index f68dc3b0..9128f7db 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -200,8 +200,8 @@ struct InvidiousPlaylist json.field "videos" do json.array do - if !offset || offset == 0 - index = PG_DB.query_one?("SELECT index FROM playlist_videos WHERE plid = $1 AND id = $2 LIMIT 1", self.id, video_id, as: Int64) + if (!offset || offset == 0) && !video_id.nil? + index = Invidious::Database::PlaylistVideos.select_index(self.id, video_id) offset = self.index.index(index) || 0 end @@ -225,7 +225,8 @@ struct InvidiousPlaylist end 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) || "-----------" + # TODO: Get playlist thumbnail from playlist data rather than first video + @thumbnail_id ||= Invidious::Database::PlaylistVideos.select_one_id(self.id, self.index) || "-----------" "/vi/#{@thumbnail_id}/mqdefault.jpg" end @@ -411,8 +412,7 @@ def get_playlist_videos(db, playlist, offset, locale = nil, video_id = nil) end if playlist.is_a? InvidiousPlaylist - 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) + Invidious::Database::PlaylistVideos.select(playlist.id, playlist.index, offset, limit: 100) else if video_id initial_data = YoutubeAPI.next({ diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index be58dd8d..b58a988f 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -15,13 +15,14 @@ module Invidious::Routes::Feeds user = user.as(User) - items_created = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + # TODO: make a single DB call and separate the items here? + items_created = Invidious::Database::Playlists.select_like_iv(user.email) items_created.map! do |item| item.author = "" item end - items_saved = PG_DB.query_all("SELECT * FROM playlists WHERE author = $1 AND id NOT LIKE 'IV%' ORDER BY created", user.email, as: InvidiousPlaylist) + items_saved = Invidious::Database::Playlists.select_not_like_iv(user.email) items_saved.map! do |item| item.author = "" item diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index b85ea59d..fa4fe083 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -138,7 +138,7 @@ we're going to need to do it here in order to allow for translations.

    <% if user %> - <% playlists = PG_DB.query_all("SELECT id,title FROM playlists WHERE author = $1 AND id LIKE 'IV%'", user.email, as: {String, String}) %> + <% playlists = Invidious::Database::Playlists.select_user_created_playlists(user.email) %> <% if !playlists.empty? %>
    -- cgit v1.2.3 From 914cfbd953d5a2c3c6f8ae98f350b60bfb38b9a2 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 6 Dec 2021 17:24:49 +0100 Subject: Move DB queries related to 'annotations' in a separate module --- src/invidious/database/annotations.cr | 24 ++++++++++++++++++++++++ src/invidious/helpers/helpers.cr | 2 +- src/invidious/routes/api/v1/videos.cr | 2 +- 3 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 src/invidious/database/annotations.cr (limited to 'src') diff --git a/src/invidious/database/annotations.cr b/src/invidious/database/annotations.cr new file mode 100644 index 00000000..03749473 --- /dev/null +++ b/src/invidious/database/annotations.cr @@ -0,0 +1,24 @@ +require "./base.cr" + +module Invidious::Database::Annotations + extend self + + def insert(id : String, annotations : String) + request = <<-SQL + INSERT INTO annotations + VALUES ($1, $2) + ON CONFLICT DO NOTHING + SQL + + PG_DB.exec(request, id, annotations) + end + + def select(id : String) : Annotation? + request = <<-SQL + SELECT * FROM annotations + WHERE id = $1 + SQL + + return PG_DB.query_one?(request, id, as: Annotation) + end +end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 014c04a8..c5f6c6c5 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -183,7 +183,7 @@ def cache_annotation(db, id, annotations) end end - db.exec("INSERT INTO annotations VALUES ($1, $2) ON CONFLICT DO NOTHING", id, annotations) if has_legacy_annotations + Invidious::Database::Annotations.insert(id, annotations) if has_legacy_annotations end def create_notification_stream(env, topics, connection_channel) diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 4c7179ce..f982329c 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -239,7 +239,7 @@ module Invidious::Routes::API::V1::Videos case source when "archive" - if CONFIG.cache_annotations && (cached_annotation = PG_DB.query_one?("SELECT * FROM annotations WHERE id = $1", id, as: Annotation)) + if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id)) annotations = cached_annotation.annotations else index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') -- cgit v1.2.3 From 6704ce3214ed8c9a3acb69e0fc53824324a610fc Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 6 Dec 2021 18:03:37 +0100 Subject: Move DB utility functions to the proper module --- src/invidious.cr | 20 +++--- src/invidious/database/base.cr | 106 ++++++++++++++++++++++++++++++++ src/invidious/helpers/helpers.cr | 105 ------------------------------- src/invidious/jobs/refresh_feeds_job.cr | 2 +- 4 files changed, 117 insertions(+), 116 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 561fc9cf..ef02f143 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -113,19 +113,19 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) # Check table integrity if CONFIG.check_tables - check_enum(PG_DB, "privacy", PlaylistPrivacy) + Invidious::Database.check_enum(PG_DB, "privacy", PlaylistPrivacy) - check_table(PG_DB, "channels", InvidiousChannel) - check_table(PG_DB, "channel_videos", ChannelVideo) - check_table(PG_DB, "playlists", InvidiousPlaylist) - check_table(PG_DB, "playlist_videos", PlaylistVideo) - check_table(PG_DB, "nonces", Nonce) - check_table(PG_DB, "session_ids", SessionId) - check_table(PG_DB, "users", User) - check_table(PG_DB, "videos", Video) + Invidious::Database.check_table(PG_DB, "channels", InvidiousChannel) + Invidious::Database.check_table(PG_DB, "channel_videos", ChannelVideo) + Invidious::Database.check_table(PG_DB, "playlists", InvidiousPlaylist) + Invidious::Database.check_table(PG_DB, "playlist_videos", PlaylistVideo) + Invidious::Database.check_table(PG_DB, "nonces", Nonce) + Invidious::Database.check_table(PG_DB, "session_ids", SessionId) + Invidious::Database.check_table(PG_DB, "users", User) + Invidious::Database.check_table(PG_DB, "videos", Video) if CONFIG.cache_annotations - check_table(PG_DB, "annotations", Annotation) + Invidious::Database.check_table(PG_DB, "annotations", Annotation) end end diff --git a/src/invidious/database/base.cr b/src/invidious/database/base.cr index 055a6284..6e49ea1a 100644 --- a/src/invidious/database/base.cr +++ b/src/invidious/database/base.cr @@ -1,4 +1,110 @@ require "pg" module Invidious::Database + extend self + + def check_enum(db, enum_name, struct_type = nil) + return # TODO + + if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) + LOGGER.info("check_enum: 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, table_name, struct_type = nil) + # Create table if it doesn't exist + begin + db.exec("SELECT * FROM #{table_name} LIMIT 0") + rescue ex + LOGGER.info("check_table: check_table: CREATE TABLE #{table_name}") + + db.using_connection do |conn| + conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql")) + end + end + + return if !struct_type + + struct_array = struct_type.type_array + column_array = get_column_array(db, table_name) + column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?[\d\D]*?)\);/) + .try &.["types"].split(",").map(&.strip).reject &.starts_with?("CONSTRAINT") + + return if !column_types + + struct_array.each_with_index do |name, i| + if name != column_array[i]? + if !column_array[i]? + new_column = column_types.select(&.starts_with?(name))[0] + LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + next + end + + # Column doesn't exist + if !column_array.includes? name + new_column = column_types.select(&.starts_with?(name))[0] + db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + end + + # Column exists but in the wrong position, rotate + if struct_array.includes? column_array[i] + until name == column_array[i] + new_column = column_types.select(&.starts_with?(column_array[i]))[0]?.try &.gsub("#{column_array[i]}", "#{column_array[i]}_new") + + # There's a column we didn't expect + if !new_column + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}") + db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + + column_array = get_column_array(db, table_name) + next + end + + LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + + LOGGER.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") + db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") + + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + + LOGGER.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") + db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") + + column_array = get_column_array(db, table_name) + end + else + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + end + end + end + + return if column_array.size <= struct_array.size + + column_array.each do |column| + if !struct_array.includes? column + LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + end + end + end + + def get_column_array(db, table_name) + column_array = [] of String + db.query("SELECT * FROM #{table_name} LIMIT 0") do |rs| + rs.column_count.times do |i| + column = rs.as(PG::ResultSet).field(i) + column_array << column.name + end + end + + return column_array + end end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index c5f6c6c5..982bcda1 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -60,111 +60,6 @@ def html_to_content(description_html : String) return description end -def check_enum(db, enum_name, struct_type = nil) - return # TODO - - if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) - LOGGER.info("check_enum: 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, table_name, struct_type = nil) - # Create table if it doesn't exist - begin - db.exec("SELECT * FROM #{table_name} LIMIT 0") - rescue ex - LOGGER.info("check_table: check_table: CREATE TABLE #{table_name}") - - db.using_connection do |conn| - conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql")) - end - end - - return if !struct_type - - struct_array = struct_type.type_array - column_array = get_column_array(db, table_name) - column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?[\d\D]*?)\);/) - .try &.["types"].split(",").map(&.strip).reject &.starts_with?("CONSTRAINT") - - return if !column_types - - struct_array.each_with_index do |name, i| - if name != column_array[i]? - if !column_array[i]? - new_column = column_types.select(&.starts_with?(name))[0] - LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - next - end - - # Column doesn't exist - if !column_array.includes? name - new_column = column_types.select(&.starts_with?(name))[0] - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - end - - # Column exists but in the wrong position, rotate - if struct_array.includes? column_array[i] - until name == column_array[i] - new_column = column_types.select(&.starts_with?(column_array[i]))[0]?.try &.gsub("#{column_array[i]}", "#{column_array[i]}_new") - - # There's a column we didn't expect - if !new_column - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - - column_array = get_column_array(db, table_name) - next - end - - LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - - LOGGER.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") - db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") - - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - - LOGGER.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") - db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") - - column_array = get_column_array(db, table_name) - end - else - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - end - end - end - - return if column_array.size <= struct_array.size - - column_array.each do |column| - if !struct_array.includes? column - LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") - end - end -end - -def get_column_array(db, table_name) - column_array = [] of String - db.query("SELECT * FROM #{table_name} LIMIT 0") do |rs| - rs.column_count.times do |i| - column = rs.as(PG::ResultSet).field(i) - column_array << column.name - end - end - - return column_array -end - def cache_annotation(db, id, annotations) if !CONFIG.cache_annotations return diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr index 926c27fa..4b52c959 100644 --- a/src/invidious/jobs/refresh_feeds_job.cr +++ b/src/invidious/jobs/refresh_feeds_job.cr @@ -25,7 +25,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob spawn do begin # Drop outdated views - column_array = get_column_array(db, view_name) + column_array = Invidious::Database.get_column_array(db, view_name) ChannelVideo.type_array.each_with_index do |name, i| if name != column_array[i]? LOGGER.info("RefreshFeedsJob: DROP MATERIALIZED VIEW #{view_name}") -- cgit v1.2.3 From 9bad7e29405536abfe35dcd1c4315918659a5d3c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 7 Dec 2021 03:40:15 +0100 Subject: Clean useless database arguments (1/5) --- src/invidious.cr | 12 ++++++------ src/invidious/channels/channels.cr | 12 ++++++------ src/invidious/routes/api/v1/authenticated.cr | 2 +- src/invidious/users.cr | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index ef02f143..e1d3e37b 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -603,7 +603,7 @@ post "/subscription_ajax" do |env| case action when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id - get_channel(channel_id, PG_DB, false, false) + get_channel(channel_id, false, false) Invidious::Database::Users.subscribe_channel(user, channel_id) end when "action_remove_subscriptions" @@ -757,7 +757,7 @@ post "/data_control" do |env| user.subscriptions += body["subscriptions"].as_a.map(&.as_s) user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, false, false) Invidious::Database::Users.update_subscriptions(user) end @@ -829,7 +829,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, false, false) Invidious::Database::Users.update_subscriptions(user) when "import_freetube" @@ -838,7 +838,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, false, false) Invidious::Database::Users.update_subscriptions(user) when "import_newpipe_subscriptions" @@ -857,7 +857,7 @@ post "/data_control" do |env| end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, false, false) Invidious::Database::Users.update_subscriptions(user) when "import_newpipe" @@ -876,7 +876,7 @@ post "/data_control" do |env| user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/")) user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, PG_DB, false, false) + user.subscriptions = get_batch_channels(user.subscriptions, false, false) Invidious::Database::Users.update_subscriptions(user) diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 2ec510f0..155ec559 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -114,7 +114,7 @@ class ChannelRedirect < Exception end end -def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, max_threads = 10) +def get_batch_channels(channels, refresh = false, pull_all_videos = true, max_threads = 10) finished_channel = Channel(String | Nil).new spawn do @@ -130,7 +130,7 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma active_threads += 1 spawn do begin - get_channel(ucid, db, refresh, pull_all_videos) + get_channel(ucid, refresh, pull_all_videos) finished_channel.send(ucid) rescue ex finished_channel.send(nil) @@ -151,21 +151,21 @@ def get_batch_channels(channels, db, refresh = false, pull_all_videos = true, ma return final end -def get_channel(id, db, refresh = true, pull_all_videos = true) +def get_channel(id, refresh = true, pull_all_videos = true) if channel = Invidious::Database::Channels.select(id) if refresh && Time.utc - channel.updated > 10.minutes - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) + channel = fetch_channel(id, pull_all_videos: pull_all_videos) Invidious::Database::Channels.insert(channel, update_on_conflict: true) end else - channel = fetch_channel(id, db, pull_all_videos: pull_all_videos) + channel = fetch_channel(id, pull_all_videos: pull_all_videos) Invidious::Database::Channels.insert(channel) end return channel end -def fetch_channel(ucid, db, pull_all_videos = true, locale = nil) +def fetch_channel(ucid, pull_all_videos = true, locale = nil) LOGGER.debug("fetch_channel: #{ucid}") LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 62b09f79..c4c06420 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -92,7 +92,7 @@ module Invidious::Routes::API::V1::Authenticated ucid = env.params.url["ucid"] if !user.subscriptions.includes? ucid - get_channel(ucid, PG_DB, false, false) + get_channel(ucid, false, false) Invidious::Database::Users.subscribe_channel(user, ucid) end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index efc4dd52..ef97d3d6 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -74,7 +74,7 @@ def fetch_user(sid, headers, db) end end - channels = get_batch_channels(channels, db, false, false) + channels = get_batch_channels(channels, false, false) email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"])) if email -- cgit v1.2.3 From c25d664edcd300c920cb22a419b5cd98d31ce3a2 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 6 Dec 2021 22:28:16 +0100 Subject: Clean useless database arguments (2/5) --- src/invidious.cr | 32 ++++++++++++++-------------- src/invidious/helpers/handlers.cr | 2 +- src/invidious/helpers/tokens.cr | 6 +++--- src/invidious/routes/api/v1/authenticated.cr | 4 ++-- src/invidious/routes/feeds.cr | 2 +- src/invidious/routes/login.cr | 12 +++++------ src/invidious/routes/playlists.cr | 14 ++++++------ src/invidious/users.cr | 20 ++++++++--------- 8 files changed, 46 insertions(+), 46 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index e1d3e37b..149fdef3 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -256,7 +256,7 @@ before_all do |env| ":subscription_ajax", ":token_ajax", ":watch_ajax", - }, HMAC_KEY, PG_DB, 1.week) + }, HMAC_KEY, 1.week) preferences = user.preferences env.set "preferences", preferences @@ -270,7 +270,7 @@ before_all do |env| headers["Cookie"] = env.request.headers["Cookie"] begin - user, sid = get_user(sid, headers, PG_DB, false) + user, sid = get_user(sid, headers, false) csrf_token = generate_response(sid, { ":authorize_token", ":playlist_ajax", @@ -278,7 +278,7 @@ before_all do |env| ":subscription_ajax", ":token_ajax", ":watch_ajax", - }, HMAC_KEY, PG_DB, 1.week) + }, HMAC_KEY, 1.week) preferences = user.preferences env.set "preferences", preferences @@ -438,7 +438,7 @@ post "/watch_ajax" do |env| end begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex if redirect next error_template(400, ex) @@ -575,7 +575,7 @@ post "/subscription_ajax" do |env| token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex if redirect next error_template(400, ex) @@ -639,7 +639,7 @@ get "/subscription_manager" do |env| headers = HTTP::Headers.new headers["Cookie"] = env.request.headers["Cookie"] - user, sid = get_user(sid, headers, PG_DB) + user, sid = get_user(sid, headers) end action_takeout = env.params.query["action_takeout"]?.try &.to_i? @@ -906,7 +906,7 @@ get "/change_password" do |env| user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY) templated "change_password" end @@ -932,7 +932,7 @@ post "/change_password" do |env| end begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex next error_template(400, ex) end @@ -980,7 +980,7 @@ get "/delete_account" do |env| user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY) templated "delete_account" end @@ -1001,7 +1001,7 @@ post "/delete_account" do |env| token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex next error_template(400, ex) end @@ -1032,7 +1032,7 @@ get "/clear_watch_history" do |env| user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY) templated "clear_watch_history" end @@ -1053,7 +1053,7 @@ post "/clear_watch_history" do |env| token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex next error_template(400, ex) end @@ -1075,7 +1075,7 @@ get "/authorize_token" do |env| user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY) scopes = env.params.query["scopes"]?.try &.split(",") scopes ||= [] of String @@ -1106,7 +1106,7 @@ post "/authorize_token" do |env| token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex next error_template(400, ex) end @@ -1115,7 +1115,7 @@ post "/authorize_token" do |env| callback_url = env.params.body["callbackUrl"]? expire = env.params.body["expire"]?.try &.to_i? - access_token = generate_token(user.email, scopes, expire, HMAC_KEY, PG_DB) + access_token = generate_token(user.email, scopes, expire, HMAC_KEY) if callback_url access_token = URI.encode_www_form(access_token) @@ -1179,7 +1179,7 @@ post "/token_ajax" do |env| token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex if redirect next error_template(400, ex) diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index d52035c7..d140a858 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -97,7 +97,7 @@ class AuthHandler < Kemal::Handler if token = env.request.headers["Authorization"]? token = JSON.parse(URI.decode_www_form(token.lchop("Bearer "))) session = URI.decode_www_form(token["session"].as_s) - scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, PG_DB, nil) + scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, nil) if email = Invidious::Database::SessionIDs.select_email(session) user = Invidious::Database::Users.select!(email: email) diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index 91405822..8b076e39 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -1,6 +1,6 @@ require "crypto/subtle" -def generate_token(email, scopes, expire, key, db) +def generate_token(email, scopes, expire, key) session = "v1:#{Base64.urlsafe_encode(Random::Secure.random_bytes(32))}" Invidious::Database::SessionIDs.insert(session, email) @@ -19,7 +19,7 @@ def generate_token(email, scopes, expire, key, db) return token.to_json end -def generate_response(session, scopes, key, db, expire = 6.hours, use_nonce = false) +def generate_response(session, scopes, key, expire = 6.hours, use_nonce = false) expire = Time.utc + expire token = { @@ -63,7 +63,7 @@ def sign_token(key, hash) return Base64.urlsafe_encode(OpenSSL::HMAC.digest(:sha256, key, string_to_sign)).strip end -def validate_request(token, session, request, key, db, locale = nil) +def validate_request(token, session, request, key, locale = nil) case token when String token = JSON.parse(URI.decode_www_form(token)).as_h diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index c4c06420..7ebc71fe 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -353,7 +353,7 @@ module Invidious::Routes::API::V1::Authenticated if sid = env.get?("sid").try &.as(String) env.response.content_type = "text/html" - csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, PG_DB, use_nonce: true) + csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true) return templated "authorize_token" else env.response.content_type = "application/json" @@ -367,7 +367,7 @@ module Invidious::Routes::API::V1::Authenticated end end - access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY, PG_DB) + access_token = generate_token(user.email, authorized_scopes, expire, HMAC_KEY) if callback_url access_token = URI.encode_www_form(access_token) diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index b58a988f..187dd247 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -84,7 +84,7 @@ module Invidious::Routes::Feeds headers["Cookie"] = env.request.headers["Cookie"] if !user.password - user, sid = get_user(sid, headers, PG_DB) + user, sid = get_user(sid, headers) end max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index c94fd09b..64da3e4e 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -275,7 +275,7 @@ module Invidious::Routes::Login raise "Couldn't get SID." end - user, sid = get_user(sid, headers, PG_DB) + user, sid = get_user(sid, headers) # We are now logged in traceback << "done.
    " @@ -393,9 +393,9 @@ module Invidious::Routes::Login prompt = "" if captcha_type == "image" - captcha = generate_captcha(HMAC_KEY, PG_DB) + captcha = generate_captcha(HMAC_KEY) else - captcha = generate_text_captcha(HMAC_KEY, PG_DB) + captcha = generate_text_captcha(HMAC_KEY) end return templated "login" @@ -412,7 +412,7 @@ module Invidious::Routes::Login answer = OpenSSL::HMAC.hexdigest(:sha256, HMAC_KEY, answer) begin - validate_request(tokens[0], answer, env.request, HMAC_KEY, PG_DB, locale) + validate_request(tokens[0], answer, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end @@ -427,7 +427,7 @@ module Invidious::Routes::Login error_exception = Exception.new tokens.each do |token| begin - validate_request(token, answer, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, answer, env.request, HMAC_KEY, locale) found_valid_captcha = true rescue ex error_exception = ex @@ -501,7 +501,7 @@ module Invidious::Routes::Login token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index b73782d5..a8097ab7 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -12,7 +12,7 @@ module Invidious::Routes::Playlists user = user.as(User) sid = sid.as(String) - csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":create_playlist"}, HMAC_KEY) templated "create_playlist" end @@ -31,7 +31,7 @@ module Invidious::Routes::Playlists token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end @@ -94,7 +94,7 @@ module Invidious::Routes::Playlists return env.redirect referer end - csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":delete_playlist"}, HMAC_KEY) templated "delete_playlist" end @@ -116,7 +116,7 @@ module Invidious::Routes::Playlists token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end @@ -166,7 +166,7 @@ module Invidious::Routes::Playlists videos = [] of PlaylistVideo end - csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY, PG_DB) + csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY) templated "edit_playlist" end @@ -188,7 +188,7 @@ module Invidious::Routes::Playlists token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex return error_template(400, ex) end @@ -286,7 +286,7 @@ module Invidious::Routes::Playlists token = env.params.body["csrf_token"]? begin - validate_request(token, sid, env.request, HMAC_KEY, PG_DB, locale) + validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex if redirect return error_template(400, ex) diff --git a/src/invidious/users.cr b/src/invidious/users.cr index ef97d3d6..ad836d61 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -29,31 +29,31 @@ struct User end end -def get_user(sid, headers, db, refresh = true) +def get_user(sid, headers, refresh = true) if email = Invidious::Database::SessionIDs.select_email(sid) user = Invidious::Database::Users.select!(email: email) if refresh && Time.utc - user.updated > 1.minute - user, sid = fetch_user(sid, headers, db) + user, sid = fetch_user(sid, headers) Invidious::Database::Users.insert(user, update_on_conflict: true) Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) begin view_name = "subscriptions_#{sha256(user.email)}" - db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") + PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") rescue ex end end else - user, sid = fetch_user(sid, headers, db) + user, sid = fetch_user(sid, headers) Invidious::Database::Users.insert(user, update_on_conflict: true) Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) begin view_name = "subscriptions_#{sha256(user.email)}" - db.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") + PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") rescue ex end end @@ -61,7 +61,7 @@ def get_user(sid, headers, db, refresh = true) return user, sid end -def fetch_user(sid, headers, db) +def fetch_user(sid, headers) feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) feed = XML.parse_html(feed.body) @@ -118,7 +118,7 @@ def create_user(sid, email, password) return user, sid end -def generate_captcha(key, db) +def generate_captcha(key) second = Random::Secure.rand(12) second_angle = second * 30 second = second * 5 @@ -170,16 +170,16 @@ def generate_captcha(key, db) return { question: image, - tokens: {generate_response(answer, {":login"}, key, db, use_nonce: true)}, + tokens: {generate_response(answer, {":login"}, key, use_nonce: true)}, } end -def generate_text_captcha(key, db) +def generate_text_captcha(key) response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body) response = JSON.parse(response) tokens = response["a"].as_a.map do |answer| - generate_response(answer.as_s, {":login"}, key, db, use_nonce: true) + generate_response(answer.as_s, {":login"}, key, use_nonce: true) end return { -- cgit v1.2.3 From 40ed4a0506277f62d66c7f7ed8cca54c62a78a02 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 7 Dec 2021 00:42:55 +0100 Subject: Clean useless database arguments (3/5) --- src/invidious.cr | 2 +- src/invidious/playlists.cr | 12 ++++++------ src/invidious/routes/api/v1/authenticated.cr | 2 +- src/invidious/routes/api/v1/misc.cr | 2 +- src/invidious/routes/embed.cr | 8 ++++---- src/invidious/routes/feeds.cr | 2 +- src/invidious/routes/playlists.cr | 14 +++++++------- 7 files changed, 21 insertions(+), 21 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 149fdef3..d400c0ba 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -783,7 +783,7 @@ post "/data_control" do |env| next if !description next if !privacy - playlist = create_playlist(PG_DB, title, privacy, user) + playlist = create_playlist(title, privacy, user) Invidious::Database::Playlists.update_description(playlist.id, description) videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 9128f7db..ee7e12aa 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -125,7 +125,7 @@ struct Playlist json.field "videos" do json.array do - videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id) + videos = get_playlist_videos(self, offset: offset, locale: locale, video_id: video_id) videos.each do |video| video.to_json(json) end @@ -205,7 +205,7 @@ struct InvidiousPlaylist offset = self.index.index(index) || 0 end - videos = get_playlist_videos(PG_DB, self, offset: offset, locale: locale, video_id: video_id) + videos = get_playlist_videos(self, offset: offset, locale: locale, video_id: video_id) videos.each_with_index do |video, index| video.to_json(json, offset + index) end @@ -247,7 +247,7 @@ struct InvidiousPlaylist end end -def create_playlist(db, title, privacy, user) +def create_playlist(title, privacy, user) plid = "IVPL#{Random::Secure.urlsafe_base64(24)[0, 31]}" playlist = InvidiousPlaylist.new({ @@ -267,7 +267,7 @@ def create_playlist(db, title, privacy, user) return playlist end -def subscribe_playlist(db, user, playlist) +def subscribe_playlist(user, playlist) playlist = InvidiousPlaylist.new({ title: playlist.title.byte_slice(0, 150), id: playlist.id, @@ -322,7 +322,7 @@ def produce_playlist_continuation(id, index) return continuation end -def get_playlist(db, plid, locale, refresh = true, force_refresh = false) +def get_playlist(plid, locale, refresh = true, force_refresh = false) if plid.starts_with? "IV" if playlist = Invidious::Database::Playlists.select(id: plid) return playlist @@ -404,7 +404,7 @@ def fetch_playlist(plid, locale) }) end -def get_playlist_videos(db, playlist, offset, locale = nil, video_id = nil) +def get_playlist_videos(playlist, offset, locale = nil, video_id = nil) # Show empy playlist if requested page is out of range # (e.g, when a new playlist has been created, offset will be negative) if offset >= playlist.video_count || offset < 0 diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 7ebc71fe..c3f751f7 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -150,7 +150,7 @@ module Invidious::Routes::API::V1::Authenticated return error_json(400, "User cannot have more than 100 playlists.") end - playlist = create_playlist(PG_DB, title, privacy, user) + playlist = create_playlist(title, privacy, user) env.response.headers["Location"] = "#{HOST_URL}/api/v1/auth/playlists/#{playlist.id}" env.response.status_code = 201 { diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 1621c9ef..ac0576a0 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -34,7 +34,7 @@ module Invidious::Routes::API::V1::Misc end begin - playlist = get_playlist(PG_DB, plid, locale) + playlist = get_playlist(plid, locale) rescue ex : InfoException return error_json(404, ex) rescue ex diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 2c648b5a..13422993 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -6,9 +6,9 @@ module Invidious::Routes::Embed if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") begin - playlist = get_playlist(PG_DB, plid, locale: locale) + playlist = get_playlist(plid, locale: locale) offset = env.params.query["index"]?.try &.to_i? || 0 - videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale) + videos = get_playlist_videos(playlist, offset: offset, locale: locale) rescue ex return error_template(500, ex) end @@ -60,9 +60,9 @@ module Invidious::Routes::Embed if plid begin - playlist = get_playlist(PG_DB, plid, locale: locale) + playlist = get_playlist(plid, locale: locale) offset = env.params.query["index"]?.try &.to_i? || 0 - videos = get_playlist_videos(PG_DB, playlist, offset: offset, locale: locale) + videos = get_playlist_videos(playlist, offset: offset, locale: locale) rescue ex return error_template(500, ex) end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 187dd247..458519b8 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -265,7 +265,7 @@ module Invidious::Routes::Feeds if plid.starts_with? "IV" if playlist = Invidious::Database::Playlists.select(id: plid) - videos = get_playlist_videos(PG_DB, playlist, offset: 0, locale: locale) + videos = get_playlist_videos(playlist, offset: 0, locale: locale) return XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index a8097ab7..d33c699b 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -50,7 +50,7 @@ module Invidious::Routes::Playlists return error_template(400, "User cannot have more than 100 playlists.") end - playlist = create_playlist(PG_DB, title, privacy, user) + playlist = create_playlist(title, privacy, user) env.redirect "/playlist?list=#{playlist.id}" end @@ -66,8 +66,8 @@ module Invidious::Routes::Playlists user = user.as(User) playlist_id = env.params.query["list"] - playlist = get_playlist(PG_DB, playlist_id, locale) - subscribe_playlist(PG_DB, user, playlist) + playlist = get_playlist(playlist_id, locale) + subscribe_playlist(user, playlist) env.redirect "/playlist?list=#{playlist.id}" end @@ -161,7 +161,7 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) + videos = get_playlist_videos(playlist, offset: (page - 1) * 100, locale: locale) rescue ex videos = [] of PlaylistVideo end @@ -314,7 +314,7 @@ module Invidious::Routes::Playlists begin playlist_id = env.params.query["playlist_id"] - playlist = get_playlist(PG_DB, playlist_id, locale).as(InvidiousPlaylist) + playlist = get_playlist(playlist_id, locale).as(InvidiousPlaylist) raise "Invalid user" if playlist.author != user.email rescue ex if redirect @@ -405,7 +405,7 @@ module Invidious::Routes::Playlists end begin - playlist = get_playlist(PG_DB, plid, locale) + playlist = get_playlist(plid, locale) rescue ex return error_template(500, ex) end @@ -422,7 +422,7 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(PG_DB, playlist, offset: (page - 1) * 100, locale: locale) + videos = get_playlist_videos(playlist, offset: (page - 1) * 100, locale: locale) rescue ex return error_template(500, "Error encountered while retrieving playlist videos.
    #{ex.message}") end -- cgit v1.2.3 From d74873fed1a4da2c2eb51a47932207b65ca473e5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 7 Dec 2021 02:55:43 +0100 Subject: Clean useless database arguments (4/5) --- src/invidious.cr | 2 +- src/invidious/helpers/helpers.cr | 6 +++--- src/invidious/routes/api/manifest.cr | 2 +- src/invidious/routes/api/v1/authenticated.cr | 2 +- src/invidious/routes/api/v1/videos.cr | 8 ++++---- src/invidious/routes/embed.cr | 4 ++-- src/invidious/routes/feeds.cr | 2 +- src/invidious/routes/playlists.cr | 2 +- src/invidious/routes/video_playback.cr | 2 +- src/invidious/routes/watch.cr | 4 ++-- src/invidious/videos.cr | 4 ++-- 11 files changed, 19 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index d400c0ba..fb67af87 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -793,7 +793,7 @@ post "/data_control" do |env| next if !video_id begin - video = get_video(video_id, PG_DB) + video = get_video(video_id) rescue ex next end diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index 982bcda1..c3b53339 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -60,7 +60,7 @@ def html_to_content(description_html : String) return description end -def cache_annotation(db, id, annotations) +def cache_annotation(id, annotations) if !CONFIG.cache_annotations return end @@ -99,7 +99,7 @@ def create_notification_stream(env, topics, connection_channel) published = Time.utc - Time::Span.new(days: time_span[0], hours: time_span[1], minutes: time_span[2], seconds: time_span[3]) video_id = TEST_IDS[rand(TEST_IDS.size)] - video = get_video(video_id, PG_DB) + video = get_video(video_id) video.published = published response = JSON.parse(video.to_json(locale, nil)) @@ -176,7 +176,7 @@ def create_notification_stream(env, topics, connection_channel) next end - video = get_video(video_id, PG_DB) + video = get_video(video_id) video.published = Time.unix(published) response = JSON.parse(video.to_json(locale, nil)) diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index 12687ec6..b6183001 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -13,7 +13,7 @@ module Invidious::Routes::API::Manifest unique_res = env.params.query["unique_res"]?.try { |q| (q == "true" || q == "1").to_unsafe } begin - video = get_video(id, PG_DB, region: region) + video = get_video(id, region: region) rescue ex : VideoRedirect return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index c3f751f7..44603c9a 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -245,7 +245,7 @@ module Invidious::Routes::API::V1::Authenticated end begin - video = get_video(video_id, PG_DB) + video = get_video(video_id) rescue ex return error_json(500, ex) end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index f982329c..4d244e7f 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -8,7 +8,7 @@ module Invidious::Routes::API::V1::Videos region = env.params.query["region"]? begin - video = get_video(id, PG_DB, region: region) + video = get_video(id, region: region) rescue ex : VideoRedirect env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) @@ -36,7 +36,7 @@ module Invidious::Routes::API::V1::Videos # getting video info. begin - video = get_video(id, PG_DB, region: region) + video = get_video(id, region: region) rescue ex : VideoRedirect env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) @@ -157,7 +157,7 @@ module Invidious::Routes::API::V1::Videos region = env.params.query["region"]? begin - video = get_video(id, PG_DB, region: region) + video = get_video(id, region: region) rescue ex : VideoRedirect env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) @@ -271,7 +271,7 @@ module Invidious::Routes::API::V1::Videos annotations = response.body - cache_annotation(PG_DB, id, annotations) + cache_annotation(id, annotations) end else # "youtube" response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 13422993..ab722ae2 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -30,7 +30,7 @@ module Invidious::Routes::Embed id = env.params.url["id"] plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") - continuation = process_continuation(PG_DB, env.params.query, plid, id) + continuation = process_continuation(env.params.query, plid, id) if md = env.params.query["playlist"]? .try &.match(/[a-zA-Z0-9_-]{11}(,[a-zA-Z0-9_-]{11})*/) @@ -119,7 +119,7 @@ module Invidious::Routes::Embed subscriptions ||= [] of String begin - video = get_video(id, PG_DB, region: params.region) + video = get_video(id, region: params.region) rescue ex : VideoRedirect return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 458519b8..5dcef351 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -393,7 +393,7 @@ module Invidious::Routes::Feeds published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - video = get_video(id, PG_DB, force_refresh: true) + video = get_video(id, force_refresh: true) # Deliver notifications to `/api/v1/auth/notifications` payload = { diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index d33c699b..d437b79c 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -345,7 +345,7 @@ module Invidious::Routes::Playlists video_id = env.params.query["video_id"] begin - video = get_video(video_id, PG_DB) + video = get_video(video_id) rescue ex if redirect return error_template(500, ex) diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 06ba6b8c..8a58b034 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -263,7 +263,7 @@ module Invidious::Routes::VideoPlayback haltf env, status_code: 400, response: "TESTING" end - video = get_video(id, PG_DB, region: region) + video = get_video(id, region: region) fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } url = fmt.try &.["url"]?.try &.as_s diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index f7bd7d81..1198f48f 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -39,7 +39,7 @@ module Invidious::Routes::Watch end plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") - continuation = process_continuation(PG_DB, env.params.query, plid, id) + continuation = process_continuation(env.params.query, plid, id) nojs = env.params.query["nojs"]? @@ -60,7 +60,7 @@ module Invidious::Routes::Watch env.params.query.delete_all("listen") begin - video = get_video(id, PG_DB, region: params.region) + video = get_video(id, region: params.region) rescue ex : VideoRedirect return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 645d3678..6c89b445 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -993,7 +993,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ return params end -def get_video(id, db, refresh = true, region = nil, force_refresh = false) +def get_video(id, refresh = true, region = nil, force_refresh = false) if (video = Invidious::Database::Videos.select(id)) && !region # If record was last updated over 10 minutes ago, or video has since premiered, # refresh (expire param in response lasts for 6 hours) @@ -1056,7 +1056,7 @@ def itag_to_metadata?(itag : JSON::Any) return VIDEO_FORMATS[itag.to_s]? end -def process_continuation(db, query, plid, id) +def process_continuation(query, plid, id) continuation = nil if plid if index = query["index"]?.try &.to_i? -- cgit v1.2.3 From 302fecbdcb8924b2f3a7cf8905f901c044cec728 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 7 Dec 2021 03:57:18 +0100 Subject: Clean useless database arguments (5/5) --- src/invidious/helpers/utils.cr | 2 -- src/invidious/routes/api/v1/authenticated.cr | 2 +- src/invidious/routes/feeds.cr | 4 ++-- src/invidious/users.cr | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 7bbbcb92..8453d605 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -1,5 +1,3 @@ -require "db" - # See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html def ci_lower_bound(pos, n) if n == 0 diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 44603c9a..fda655ef 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -44,7 +44,7 @@ module Invidious::Routes::API::V1::Authenticated page = env.params.query["page"]?.try &.to_i? page ||= 1 - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + videos, notifications = get_subscription_feed(user, max_results, page) JSON.build do |json| json.object do diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 5dcef351..fd8c25ce 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -94,7 +94,7 @@ module Invidious::Routes::Feeds page = env.params.query["page"]?.try &.to_i? page ||= 1 - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + videos, notifications = get_subscription_feed(user, max_results, page) # "updated" here is used for delivering new notifications, so if # we know a user has looked at their feed e.g. in the past 10 minutes, @@ -234,7 +234,7 @@ module Invidious::Routes::Feeds params = HTTP::Params.parse(env.params.query["params"]? || "") - videos, notifications = get_subscription_feed(PG_DB, user, max_results, page) + videos, notifications = get_subscription_feed(user, max_results, page) XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", diff --git a/src/invidious/users.cr b/src/invidious/users.cr index ad836d61..49074994 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -220,7 +220,7 @@ def subscribe_ajax(channel_id, action, env_headers) end end -def get_subscription_feed(db, user, max_results = 40, page = 1) +def get_subscription_feed(user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit -- cgit v1.2.3 From 68cbc11810fd746a776f6b8fefd61cec89ac9b55 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 7 Dec 2021 04:30:05 +0100 Subject: Fix the search box Fix #277 : autoselect search field Fix #1107: no spell checking/auto-correct on search field --- assets/css/default.css | 34 +++++++++++---------------- src/invidious/views/components/search_box.ecr | 9 +++++++ src/invidious/views/search_homepage.ecr | 6 +---- src/invidious/views/template.ecr | 6 +---- 4 files changed, 25 insertions(+), 30 deletions(-) create mode 100644 src/invidious/views/components/search_box.ecr (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 95c1f55c..2cda980c 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -192,20 +192,24 @@ img.thumbnail { display: inline; } -.searchbar .pure-form input[type="search"] { - margin-bottom: 1px; +.searchbar .pure-form fieldset { padding: 0; } - border-top: 0; - border-left: 0; - border-right: 0; - border-bottom: 1px solid #ccc; - border-radius: 0; +.searchbar input[type="search"] { + width: 100%; + margin: 1px; - padding: initial 0; + border: 1px solid; + border-color: #0000 #0000 #CCC #0000; + border-radius: 0; - box-shadow: none; + box-shadow: none; + -webkit-appearance: none; +} - -webkit-appearance: none; +.searchbar input[type="search"]:focus { + margin: 0 0 0.5px 0; + border: 2px solid; + border-color: #0000 #0000 #FED #0000; } /* https://stackoverflow.com/a/55170420 */ @@ -217,16 +221,6 @@ input[type="search"]::-webkit-search-cancel-button { background-size: 14px; } -.searchbar .pure-form fieldset { - padding: 0; -} - -/* attract focus to the searchbar by adding a subtle transition */ -.searchbar .pure-form input[type="search"]:focus { - margin-bottom: 0px; - border-bottom: 2px solid #aaa; -} - .user-field { display: flex; flex-direction: row; diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr new file mode 100644 index 00000000..4144d161 --- /dev/null +++ b/src/invidious/views/components/search_box.ecr @@ -0,0 +1,9 @@ + +
    + " + title="<%= translate(locale, "search") %>" + value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> +
    + diff --git a/src/invidious/views/search_homepage.ecr b/src/invidious/views/search_homepage.ecr index 7d2dab83..45561d1e 100644 --- a/src/invidious/views/search_homepage.ecr +++ b/src/invidious/views/search_homepage.ecr @@ -14,11 +14,7 @@
    diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 5b6e6ab8..efa434bf 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -35,11 +35,7 @@ Invidious
    <% end %> -- cgit v1.2.3 From 1769b0fdcebbbd6a42deb49d5e8ee7150d8b2d25 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 7 Dec 2021 04:55:14 +0100 Subject: Fix "video can't be added to playlist without JS" Fixes #2686 --- src/invidious/views/watch.ecr | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index b85ea59d..52262df7 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -140,7 +140,7 @@ we're going to need to do it here in order to allow for translations. <% if user %> <% playlists = PG_DB.query_all("SELECT id,title FROM playlists WHERE author = $1 AND id LIKE 'IV%'", user.email, as: {String, String}) %> <% if !playlists.empty? %> -
    +
    + "> + + -- cgit v1.2.3 From 6c8a5a1e7f5ab33fd4f5f4ab9c251af0aac563de Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 7 Dec 2021 05:40:19 +0100 Subject: Fix leading spaces being collapsed in descriptions Fixes #1954 --- assets/css/default.css | 3 ++- src/invidious/channels/about.cr | 4 ++-- src/invidious/comments.cr | 4 ++-- src/invidious/playlists.cr | 2 +- src/invidious/videos.cr | 2 +- src/invidious/views/playlist.ecr | 4 +--- src/invidious/views/watch.ecr | 8 ++------ 7 files changed, 11 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 2cda980c..8b2b3578 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -540,7 +540,8 @@ p, } #descriptionWrapper { - max-width: 600px; + max-width: 600px; + white-space: pre-wrap; } /* Center the "invidious" logo on the search page */ diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index c87c53e0..d93ee681 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -59,7 +59,7 @@ def get_about_info(ucid, locale) banner = banners.try &.[-1]?.try &.["url"].as_s? description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s - description_html = HTML.escape(description).gsub("\n", "
    ") + description_html = HTML.escape(description) is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) @@ -81,7 +81,7 @@ def get_about_info(ucid, locale) # end description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" - description_html = HTML.escape(description).gsub("\n", "
    ") + description_html = HTML.escape(description) is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 12a80bc4..5b7d63e0 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -550,12 +550,12 @@ end def parse_content(content : JSON::Any) : String content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || - content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s } || "" + content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s.gsub("\n", "
    ") } || "" end def content_to_comment_html(content) comment_html = content.map do |run| - text = HTML.escape(run["text"].as_s).gsub("\n", "
    ") + text = HTML.escape(run["text"].as_s) if run["bold"]? text = "#{text}" diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index f37667b5..fc41ecd2 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -242,7 +242,7 @@ struct InvidiousPlaylist end def description_html - HTML.escape(self.description).gsub("\n", "
    ") + HTML.escape(self.description) end end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d4ef0900..b1c60947 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -945,7 +945,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ # Description description_html = video_secondary_renderer.try &.dig?("description", "runs") - .try &.as_a.try { |t| content_to_comment_html(t).gsub("\n", "
    ") } + .try &.as_a.try { |t| content_to_comment_html(t) } params["descriptionHtml"] = JSON::Any.new(description_html || "

    ") diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 136981da..dd918404 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -74,9 +74,7 @@
    -
    -

    <%= playlist.description_html %>

    -
    +
    <%= playlist.description_html %>
    <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 52262df7..11e738ab 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -257,14 +257,10 @@ we're going to need to do it here in order to allow for translations.
    <% if video.description.size < 200 || params.extend_desc %> -
    - <%= video.description_html %> -
    +
    <%= video.description_html %>
    <% else %> -
    - <%= video.description_html %> -
    +
    <%= video.description_html %>
    -- cgit v1.2.3 From 3b1a2862907ed5a567db9aad6180cad730f590ac Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 8 Dec 2021 01:38:09 +0100 Subject: Use dig?() for playlist title --- src/invidious/playlists.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index fc41ecd2..1a8c2adc 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -352,7 +352,7 @@ def fetch_playlist(plid, locale) playlist_info = playlist_sidebar_renderer[0]["playlistSidebarPrimaryInfoRenderer"]? raise InfoException.new("Could not extract playlist info") if !playlist_info - title = playlist_info["title"]?.try &.["runs"][0]?.try &.["text"]?.try &.as_s || "" + title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || "" desc_item = playlist_info["description"]? -- cgit v1.2.3 From 444b1c99d0a0c69b6dc60559ef6b1647984623ab Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 5 Jan 2022 02:43:17 +0100 Subject: Show unavailable videos in playlists --- spec/helpers_spec.cr | 6 +++--- src/invidious/playlists.cr | 14 ++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/spec/helpers_spec.cr b/spec/helpers_spec.cr index 4215b2bd..c1592048 100644 --- a/spec/helpers_spec.cr +++ b/spec/helpers_spec.cr @@ -43,11 +43,11 @@ describe "Helper" do describe "#produce_playlist_continuation" do it "correctly produces ctoken for requesting index `x` of a playlist" do - produce_playlist_continuation("UUCla9fZca4I7KagBtgRGnOw", 100).should eq("4qmFsgJNEhpWTFVVQ2xhOWZaY2E0STdLYWdCdGdSR25PdxoUQ0FGNkJsQlVPa05IVVElM0QlM0SaAhhVVUNsYTlmWmNhNEk3S2FnQnRnUkduT3c%3D") + produce_playlist_continuation("UUCla9fZca4I7KagBtgRGnOw", 100).should eq("4qmFsgJNEhpWTFVVQ2xhOWZaY2E0STdLYWdCdGdSR25PdxoUQ0FGNkJsQlVPa05CUmNJR0FnZ0GaAhhVVUNsYTlmWmNhNEk3S2FnQnRnUkduT3c%3D") - produce_playlist_continuation("UCCla9fZca4I7KagBtgRGnOw", 200).should eq("4qmFsgJLEhpWTFVVQ2xhOWZaY2E0STdLYWdCdGdSR25PdxoSQ0FKNkIxQlVPa05OWjBJJTNEmgIYVVVDbGE5ZlpjYTRJN0thZ0J0Z1JHbk93") + produce_playlist_continuation("UCCla9fZca4I7KagBtgRGnOw", 200).should eq("4qmFsgJNEhpWTFVDQ2xhOWZaY2E0STdLYWdCdGdSR25PdxoUQ0FGNkJsQlVPa05CU2NJR0FnZ0GaAhhVQ0NsYTlmWmNhNEk3S2FnQnRnUkduT3c%3D") - produce_playlist_continuation("PL55713C70BA91BD6E", 100).should eq("4qmFsgJBEhRWTFBMNTU3MTNDNzBCQTkxQkQ2RRoUQ0FGNkJsQlVPa05IVVElM0QlM0SaAhJQTDU1NzEzQzcwQkE5MUJENkU%3D") + produce_playlist_continuation("PL55713C70BA91BD6E", 100).should eq("4qmFsgJDEhRWTFBMNTU3MTNDNzBCQTkxQkQ2RRoUQ0FGNkJsQlVPa05CUmNJR0FnZ0GaAhRVQ1BMNTU3MTNDNzBCQTkxQkQ2RQ%3D%3D") end end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 1a8c2adc..e40be974 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -305,16 +305,14 @@ def produce_playlist_continuation(id, index) .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i, padding: false) } - data_wrapper = {"1:varint" => request_count, "15:string" => "PT:#{data}"} - .try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - object = { "80226972:embedded" => { - "2:string" => plid, - "3:string" => data_wrapper, + "2:string" => plid, + "3:base64" => { + "1:varint" => request_count, + "15:string" => "PT:#{data}", + "104:embedded" => {"1:0:varint" => 0_i64}, + }, "35:string" => id, }, } -- cgit v1.2.3 From bf0a48847c9f4ab0de002d28a65968e4c44352a2 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 5 Jan 2022 19:58:24 +0100 Subject: DB: fix subscription not being cleared Fixes https://github.com/iv-org/invidious/issues/2764 --- src/invidious/database/users.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr index 71650918..53724dbf 100644 --- a/src/invidious/database/users.cr +++ b/src/invidious/database/users.cr @@ -143,11 +143,11 @@ module Invidious::Database::Users def clear_notifications(user : User) request = <<-SQL UPDATE users - SET notifications = $1, updated = $2 - WHERE email = $3 + SET notifications = '{}', updated = $1 + WHERE email = $2 SQL - PG_DB.exec(request, [] of String, Time.utc, user) + PG_DB.exec(request, Time.utc, user.email) end # ------------------- -- cgit v1.2.3 From e1219cbdef9514a43a32516f1d86713d676b4ab0 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Wed, 5 Jan 2022 18:24:04 -0600 Subject: Fix playlist deletion --- src/invidious/database/playlists.cr | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index 950d5f4b..93f62d10 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -21,13 +21,12 @@ module Invidious::Database::Playlists PG_DB.exec(request, args: playlist_array) end - # this function is a bit special: it will also remove all videos - # related to the given playlist ID in the "playlist_videos" table, - # in addition to deleting said ID from "playlists". + # deletes the given playlist and connected playlist videos def delete(id : String) + PlaylistVideos.delete_by_playlist_id(id) request = <<-SQL - DELETE FROM playlist_videos * WHERE plid = $1; - DELETE FROM playlists * WHERE id = $1 + DELETE FROM playlists * + WHERE id = $1 SQL PG_DB.exec(request, id) @@ -207,6 +206,15 @@ module Invidious::Database::PlaylistVideos PG_DB.exec(request, index) end + def delete_by_playlist_id(playlist_id) + request = <<-SQL + DELETE FROM playlist_videos * + WHERE plid = $1; + SQL + + PG_DB.exec(request, playlist_id) + end + # ------------------- # Salect # ------------------- -- cgit v1.2.3 From ba0bc72d0b05a5a03ccf3d441011cf9ec0929ba4 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Wed, 5 Jan 2022 19:03:32 -0600 Subject: delete_by_playlist_id -> delete_by_playlist --- src/invidious/database/playlists.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index 93f62d10..1d846018 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -23,7 +23,7 @@ module Invidious::Database::Playlists # deletes the given playlist and connected playlist videos def delete(id : String) - PlaylistVideos.delete_by_playlist_id(id) + PlaylistVideos.delete_by_playlist(id) request = <<-SQL DELETE FROM playlists * WHERE id = $1 @@ -206,7 +206,7 @@ module Invidious::Database::PlaylistVideos PG_DB.exec(request, index) end - def delete_by_playlist_id(playlist_id) + def delete_by_playlist(playlist_id : String) request = <<-SQL DELETE FROM playlist_videos * WHERE plid = $1; -- cgit v1.2.3 From 2eb7c5c0374db96e935e6e81cc2dae5112295e1c Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Wed, 5 Jan 2022 20:15:42 -0600 Subject: PR feedback --- src/invidious/database/playlists.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index 1d846018..7a5f61dc 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -206,13 +206,13 @@ module Invidious::Database::PlaylistVideos PG_DB.exec(request, index) end - def delete_by_playlist(playlist_id : String) + def delete_by_playlist(plid : String) request = <<-SQL DELETE FROM playlist_videos * - WHERE plid = $1; + WHERE plid = $1 SQL - PG_DB.exec(request, playlist_id) + PG_DB.exec(request, plid) end # ------------------- -- cgit v1.2.3 From 7cbd79fee5f87c5c611685100ef8167d90b831f5 Mon Sep 17 00:00:00 2001 From: bbielsa Date: Tue, 26 Oct 2021 21:19:20 -0400 Subject: Add helper function parse_subscription_export_csv() which parses the csv format returned by the subscription exporter --- src/invidious/helpers/utils.cr | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 8453d605..6d12fe8d 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -1,3 +1,5 @@ +require "csv" + # See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html def ci_lower_bound(pos, n) if n == 0 @@ -367,3 +369,23 @@ def fetch_random_instance return filtered_instance_list.sample(1)[0] end + +def parse_subscription_export_csv(csv_content : String) + rows = CSV.new(csv_content, headers: true) + subscriptions = Array(String).new + + rows.each do |row| + # Channel ID is the first column in the csv export we can't use the header + # name, because I believe the header name is localized depending on the + # language the user has set on their account + channel_id = row[0].strip + + if channel_id.empty? + next + end + + subscriptions << channel_id + end + + subscriptions +end -- cgit v1.2.3 From 43ff3be751920bedb394ff5cf8cd27812131c489 Mon Sep 17 00:00:00 2001 From: bbielsa Date: Wed, 27 Oct 2021 17:54:40 -0400 Subject: Test if body content is likely JSON, if so parse the json format of subscriptions export. If the content is anything else, assume it is CSV and parse --- src/invidious.cr | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index fb67af87..3a358c20 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -821,11 +821,14 @@ post "/data_control" do |env| user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] end - else + elsif body[0] == '[' subscriptions = JSON.parse(body) user.subscriptions += subscriptions.as_a.compact_map do |entry| entry["snippet"]["resourceId"]["channelId"].as_s end + else + subscriptions = parse_subscription_export_csv(body) + user.subscriptions += subscriptions end user.subscriptions.uniq! -- cgit v1.2.3 From 62057e676a4f4359b9e977b9a5aa055c61e16c8e Mon Sep 17 00:00:00 2001 From: bbielsa Date: Wed, 3 Nov 2021 00:31:43 -0400 Subject: Move parse_subscription_export_csv function to user/imports.cr --- src/invidious/helpers/utils.cr | 20 -------------------- src/invidious/user/imports.cr | 17 +++++++++++++++++ 2 files changed, 17 insertions(+), 20 deletions(-) create mode 100644 src/invidious/user/imports.cr (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 6d12fe8d..8bf6b272 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -369,23 +369,3 @@ def fetch_random_instance return filtered_instance_list.sample(1)[0] end - -def parse_subscription_export_csv(csv_content : String) - rows = CSV.new(csv_content, headers: true) - subscriptions = Array(String).new - - rows.each do |row| - # Channel ID is the first column in the csv export we can't use the header - # name, because I believe the header name is localized depending on the - # language the user has set on their account - channel_id = row[0].strip - - if channel_id.empty? - next - end - - subscriptions << channel_id - end - - subscriptions -end diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr new file mode 100644 index 00000000..0ea554bd --- /dev/null +++ b/src/invidious/user/imports.cr @@ -0,0 +1,17 @@ +def parse_subscription_export_csv(csv_content : String) + rows = CSV.new(csv_content, headers: true) + subscriptions = Array(String).new + + rows.each do |row| + # Channel ID is the first column in the csv export we can't use the header + # name, because the header name is localized depending on the + # language the user has set on their account + channel_id = row[0].strip + + next if channel_id.empty? + + subscriptions << channel_id + end + + subscriptions +end -- cgit v1.2.3 From 9607fe03af8dc02a53ffd05df6f815e675bfadae Mon Sep 17 00:00:00 2001 From: bbielsa Date: Wed, 3 Nov 2021 00:45:03 -0400 Subject: Detect the type of subscription import format based on the content type of the file uploaded --- src/invidious.cr | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 3a358c20..bdecff1d 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -746,6 +746,8 @@ post "/data_control" do |env| HTTP::FormData.parse(env.request) do |part| body = part.body.gets_to_end + type = part.headers["Content-Type"] + next if body.empty? # TODO: Unify into single import based on content-type @@ -816,12 +818,12 @@ post "/data_control" do |env| end end when "import_youtube" - if body[0..4] == " Date: Wed, 3 Nov 2021 19:57:00 -0400 Subject: Add text/xml as a possible mime type for xml file uploads --- src/invidious.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index bdecff1d..85053da2 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -818,7 +818,7 @@ post "/data_control" do |env| end end when "import_youtube" - if type == "application/xml" + if type == "application/xml" || type == "text/xml" subscriptions = XML.parse(body) user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] -- cgit v1.2.3 From 0a66a68db8630e5012a3b4a03db37e862410c628 Mon Sep 17 00:00:00 2001 From: bbielsa Date: Wed, 17 Nov 2021 20:41:23 -0500 Subject: Move require statement to the correct file --- src/invidious/helpers/utils.cr | 2 -- src/invidious/user/imports.cr | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 8bf6b272..8453d605 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -1,5 +1,3 @@ -require "csv" - # See http://www.evanmiller.org/how-not-to-sort-by-average-rating.html def ci_lower_bound(pos, n) if n == 0 diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 0ea554bd..836da14d 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -1,3 +1,5 @@ +require "csv" + def parse_subscription_export_csv(csv_content : String) rows = CSV.new(csv_content, headers: true) subscriptions = Array(String).new -- cgit v1.2.3 From 6764185543fc6fad8422fb6fc00b305bb4376d37 Mon Sep 17 00:00:00 2001 From: bbielsa Date: Wed, 17 Nov 2021 20:44:04 -0500 Subject: Add explicit return keyword --- src/invidious/user/imports.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 836da14d..98a62c17 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -15,5 +15,5 @@ def parse_subscription_export_csv(csv_content : String) subscriptions << channel_id end - subscriptions + return subscriptions end -- cgit v1.2.3 From 4962c00ba8dda6496d3dbf51ffa2ddaf6718b876 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Sat, 8 Jan 2022 12:21:36 -0600 Subject: Update to fetch related channels again --- src/invidious/channels/about.cr | 150 ++++++++++++++++---------------- src/invidious/routes/api/v1/channels.cr | 32 +++---- 2 files changed, 91 insertions(+), 91 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index d93ee681..48f2d4d2 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -1,33 +1,31 @@ # TODO: Refactor into either SearchChannel or InvidiousChannel -struct AboutChannel - include DB::Serializable - - property ucid : String - property author : String - property auto_generated : Bool - property author_url : String - property author_thumbnail : String - property banner : String? - property description_html : String - property total_views : Int64 - property sub_count : Int32 - property joined : Time - property is_family_friendly : Bool - property allowed_regions : Array(String) - property related_channels : Array(AboutRelatedChannel) - property tabs : Array(String) -end - -struct AboutRelatedChannel - include DB::Serializable - - property ucid : String - property author : String - property author_url : String - property author_thumbnail : String -end - -def get_about_info(ucid, locale) +record AboutChannel, + ucid : String, + author : String, + auto_generated : Bool, + author_url : String, + author_thumbnail : String, + banner : String?, + description_html : String, + total_views : Int64, + sub_count : Int32, + joined : Time, + is_family_friendly : Bool, + allowed_regions : Array(String), + related_channels : RelatedChannels?, + tabs : Array(String) + +record RelatedChannels, + browse_id : String, + params : String? + +record AboutRelatedChannel, + ucid : String, + author : String, + author_url : String, + author_thumbnail : String + +def get_about_info(ucid, locale) : AboutChannel begin # "EgVhYm91dA==" is the base64-encoded protobuf object {"2:string":"about"} initdata = YoutubeAPI.browse(browse_id: ucid, params: "EgVhYm91dA==") @@ -49,6 +47,7 @@ def get_about_info(ucid, locale) auto_generated = true end + related_channels = nil if auto_generated author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s @@ -63,8 +62,6 @@ def get_about_info(ucid, locale) is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) - - related_channels = [] of AboutRelatedChannel else author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s @@ -86,37 +83,14 @@ def get_about_info(ucid, locale) is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) - related_channels = initdata["contents"]["twoColumnBrowseResultsRenderer"] - .["secondaryContents"]?.try &.["browseSecondaryContentsRenderer"]["contents"][0]? - .try &.["verticalChannelSectionRenderer"]?.try &.["items"]?.try &.as_a.map do |node| - renderer = node["miniChannelRenderer"]? - related_id = renderer.try &.["channelId"]?.try &.as_s? - related_id ||= "" - - related_title = renderer.try &.["title"]?.try &.["simpleText"]?.try &.as_s? - related_title ||= "" - - related_author_url = renderer.try &.["navigationEndpoint"]?.try &.["commandMetadata"]?.try &.["webCommandMetadata"]? - .try &.["url"]?.try &.as_s? - related_author_url ||= "" - - related_author_thumbnails = renderer.try &.["thumbnail"]?.try &.["thumbnails"]?.try &.as_a? - related_author_thumbnails ||= [] of JSON::Any - - related_author_thumbnail = "" - if related_author_thumbnails.size > 0 - related_author_thumbnail = related_author_thumbnails[-1]["url"]?.try &.as_s? - related_author_thumbnail ||= "" - end - - AboutRelatedChannel.new({ - ucid: related_id, - author: related_title, - author_url: related_author_url, - author_thumbnail: related_author_thumbnail, - }) + tabs = initdata.dig("contents", "twoColumnBrowseResultsRenderer", "tabs").as_a + if tab = tabs.find { |tab| tab.dig?("tabRenderer", "title").try(&.as_s?) == "Channels" } + browse_id = tab.dig?("tabRenderer", "endpoint", "browseEndpoint", "browseId").try(&.as_s?) + params = tab.dig?("tabRenderer", "endpoint", "browseEndpoint", "params").try(&.as_s?) + if browse_id + related_channels = RelatedChannels.new(browse_id: browse_id, params: params) end - related_channels ||= [] of AboutRelatedChannel + end end total_views = 0_i64 @@ -155,20 +129,44 @@ def get_about_info(ucid, locale) sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? .try { |text| short_text_to_number(text.split(" ")[0]) } || 0 - AboutChannel.new({ - ucid: ucid, - author: author, - auto_generated: auto_generated, - author_url: author_url, - author_thumbnail: author_thumbnail, - banner: banner, - description_html: description_html, - total_views: total_views, - sub_count: sub_count, - joined: joined, + AboutChannel.new( + ucid: ucid, + author: author, + auto_generated: auto_generated, + author_url: author_url, + author_thumbnail: author_thumbnail, + banner: banner, + description_html: description_html, + total_views: total_views, + sub_count: sub_count, + joined: joined, is_family_friendly: is_family_friendly, - allowed_regions: allowed_regions, - related_channels: related_channels, - tabs: tabs, - }) + allowed_regions: allowed_regions, + related_channels: related_channels, + tabs: tabs, + ) +end + +def fetch_related_channels(related_channels : RelatedChannels) : Array(AboutRelatedChannel) + channels = YoutubeAPI.browse(browse_id: related_channels.browse_id, params: related_channels.params || "") + + tabs = channels.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs").try(&.as_a?) || [] of JSON::Any + tab = tabs.find { |tab| tab.dig?("tabRenderer", "title").try(&.as_s?) == "Channels" } + return [] of AboutRelatedChannel if tab.nil? + + items = tab.dig?("tabRenderer", "content", "sectionListRenderer", "contents", 0, "itemSectionRenderer", "contents", 0, "gridRenderer", "items").try(&.as_a?) || [] of JSON::Any + + items.map do |item| + related_id = item.dig("gridChannelRenderer", "channelId").as_s + related_title = item.dig("gridChannelRenderer", "title", "simpleText").as_s + related_author_url = item.dig("gridChannelRenderer", "navigationEndpoint", "browseEndpoint", "canonicalBaseUrl").as_s + related_author_thumbnail = item.dig("gridChannelRenderer", "thumbnail", "thumbnails", -1, "url").as_s + + AboutRelatedChannel.new( + ucid: related_id, + author: related_title, + author_url: related_author_url, + author_thumbnail: related_author_thumbnail, + ) + end end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 8b6df3fd..d09641e6 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -96,21 +96,23 @@ module Invidious::Routes::API::V1::Channels json.field "relatedChannels" do json.array do - channel.related_channels.each do |related_channel| - json.object do - json.field "author", related_channel.author - json.field "authorId", related_channel.ucid - json.field "authorUrl", related_channel.author_url - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality + if related_channels = channel.related_channels + fetch_related_channels(related_channels).each do |related_channel| + json.object do + json.field "author", related_channel.author + json.field "authorId", related_channel.ucid + json.field "authorUrl", related_channel.author_url + + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end end end end -- cgit v1.2.3 From 790b7afccaeb880e8b77c1df6a4cc6654c222006 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 9 Jan 2022 22:04:10 +0100 Subject: Fix indefinitely growing database --- src/invidious/jobs/refresh_channels_job.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index c224c745..941089c1 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -13,7 +13,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob loop do LOGGER.debug("RefreshChannelsJob: Refreshing all channels") - db.query("SELECT id FROM channels ORDER BY updated") do |rs| + PG_DB.query("SELECT id FROM channels ORDER BY updated") do |rs| rs.each do id = rs.read(String) @@ -30,7 +30,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob spawn do begin LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel") - channel = fetch_channel(id, db, CONFIG.full_refresh) + channel = fetch_channel(id, CONFIG.full_refresh) lim_fibers = max_fibers -- cgit v1.2.3 From 9a48fd81a36634b41c55562eba410823448f9f68 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 18 Nov 2021 17:37:33 +0100 Subject: i18n: Add i18next plurals base sets --- src/invidious/helpers/i18next.cr | 83 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/invidious/helpers/i18next.cr (limited to 'src') diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr new file mode 100644 index 00000000..16ca594b --- /dev/null +++ b/src/invidious/helpers/i18next.cr @@ -0,0 +1,83 @@ +# I18next-compatible implementation of plural forms +# +module I18next::Plurals + # ----------------------------------- + # I18next plural forms definition + # ----------------------------------- + + private enum PluralForms + # One singular, one plural forms + Single_gt_one = 1 # E.g: French + Single_not_one = 2 # E.g: English + + # No plural forms (E.g: Azerbaijani) + None = 3 + + # One singular, two plural forms + Dual_Slavic = 4 # E.g: Russian + + # Special cases (rules used by only one or two language(s)) + Special_Arabic = 5 + Special_Czech_Slovak = 6 + Special_Polish_Kashubian = 7 + Special_Welsh = 8 + Special_Irish = 10 + Special_Scottish_Gaelic = 11 + Special_Icelandic = 12 + Special_Javanese = 13 + Special_Cornish = 14 + Special_Lithuanian = 15 + Special_Latvian = 16 + Special_Macedonian = 17 + Special_Mandinka = 18 + Special_Maltese = 19 + Special_Romanian = 20 + Special_Slovenian = 21 + Special_Hebrew = 22 + end + + private PLURAL_SETS = { + PluralForms::Single_gt_one => [ + "ach", "ak", "am", "arn", "br", "fil", "fr", "gun", "ln", "mfe", "mg", + "mi", "oc", "pt", "pt-BR", "tg", "tl", "ti", "tr", "uz", "wa", + ], + PluralForms::Single_not_one => [ + "af", "an", "ast", "az", "bg", "bn", "ca", "da", "de", "dev", "el", "en", + "eo", "es", "et", "eu", "fi", "fo", "fur", "fy", "gl", "gu", "ha", "hi", + "hu", "hy", "ia", "it", "kk", "kn", "ku", "lb", "mai", "ml", "mn", "mr", + "nah", "nap", "nb", "ne", "nl", "nn", "no", "nso", "pa", "pap", "pms", + "ps", "pt-PT", "rm", "sco", "se", "si", "so", "son", "sq", "sv", "sw", + "ta", "te", "tk", "ur", "yo", + ], + PluralForms::None => [ + "ay", "bo", "cgg", "fa", "ht", "id", "ja", "jbo", "ka", "km", "ko", "ky", + "lo", "ms", "sah", "su", "th", "tt", "ug", "vi", "wo", "zh", + ], + PluralForms::Dual_Slavic => [ + "be", "bs", "cnr", "dz", "hr", "ru", "sr", "uk", + ], + } + + private PLURAL_SINGLES = { + "ar" => PluralForms::Special_Arabic, + "cs" => PluralForms::Special_Czech_Slovak, + "csb" => PluralForms::Special_Polish_Kashubian, + "cy" => PluralForms::Special_Welsh, + "ga" => PluralForms::Special_Irish, + "gd" => PluralForms::Special_Scottish_Gaelic, + "he" => PluralForms::Special_Hebrew, + "is" => PluralForms::Special_Icelandic, + "iw" => PluralForms::Special_Hebrew, + "jv" => PluralForms::Special_Javanese, + "kw" => PluralForms::Special_Cornish, + "lt" => PluralForms::Special_Lithuanian, + "lv" => PluralForms::Special_Latvian, + "mk" => PluralForms::Special_Macedonian, + "mnk" => PluralForms::Special_Mandinka, + "mt" => PluralForms::Special_Maltese, + "pl" => PluralForms::Special_Polish_Kashubian, + "ro" => PluralForms::Special_Romanian, + "sk" => PluralForms::Special_Czech_Slovak, + "sl" => PluralForms::Special_Slovenian, + } +end -- cgit v1.2.3 From 71a1ad307c89a343880e09b8ca0b610f2106511a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 18 Nov 2021 17:44:08 +0100 Subject: i18n: Add i18next plural resolver class --- src/invidious/helpers/i18next.cr | 129 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) (limited to 'src') diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr index 16ca594b..d8b451a1 100644 --- a/src/invidious/helpers/i18next.cr +++ b/src/invidious/helpers/i18next.cr @@ -80,4 +80,133 @@ module I18next::Plurals "sk" => PluralForms::Special_Czech_Slovak, "sl" => PluralForms::Special_Slovenian, } + + # The array indices matches the PluralForms enum above + private NUMBERS = [ + [1, 2], # 1 + [1, 2], # 2 + [1], # 3 + [1, 2, 5], # 4 + [0, 1, 2, 3, 11, 100], # 5 + [1, 2, 5], # 6 + [1, 2, 5], # 7 + [1, 2, 3, 8], # 8 + [1, 2], # 9 (not used) + [1, 2, 3, 7, 11], # 10 + [1, 2, 3, 20], # 11 + [1, 2], # 12 + [0, 1], # 13 + [1, 2, 3, 4], # 14 + [1, 2, 10], # 15 + [1, 2, 0], # 16 + [1, 2], # 17 + [0, 1, 2], # 18 + [1, 2, 11, 20], # 19 + [1, 2, 20], # 20 + [5, 1, 2, 3], # 21 + [1, 2, 20, 21], # 22 + ] + + # "or" () + private NUMBERS_OR = [2, 1] + + # ----------------------------------- + # I18next plural resolver class + # ----------------------------------- + + class Resolver + @@forms : Hash(String, PluralForms) = init_rules() + @@version : UInt8 = 3 + + # Options + property simplify_plural_suffix : Bool = true + + # Suffixes + SUFFIXES_V1 = { + "", + "_plural_1", + "_plural_2", + "_plural_3", + "_plural_11", + "_plural_100", + } + SUFFIXES_V2 = {"_0", "_1", "_2", "_3", "_11", "_100"} + SUFFIXES_V3 = {"_0", "_1", "_2", "_3", "_4", "_5"} + + def initialize(version : UInt8 = 3) + # Sanity checks + # V4 isn't supported, as it requires a full CLDR database. + if version > 4 || version == 0 + raise "Invalid i18next version: v#{version}." + elsif version == 4 + # Logger.error("Unsupported i18next version: v4. Falling back to v3") + @@version = 3 + else + @@version = version + end + end + + def self.init_rules + # Look into sets + PLURAL_SETS.each do |form, langs| + langs.each { |lang| @@forms[lang] = form } + end + + # Add plurals from the "singles" set + @@forms.merge!(PLURAL_SINGLES) + end + + def get_plural_form(locale : String) : PluralForms + # Extract the ISO 639-1 or 639-2 code from an RFC 5646 + # language code, except for pt-BR which needs to be kept as-is. + if locale.starts_with?("pt-BR") + locale = "pt-BR" + else + locale = locale.split('-')[0] + end + + return @@forms[locale] if @@forms[locale]? + + # If nothing was found, then use the most common form, i.e + # one singular and one plural, as in english. Not perfect, + # but better than yielding an exception at the user. + return PluralForms::Single_not_one + end + + def get_suffix(locale : String, count : Int) : String + # Checked count must be absolute. In i18next, `rule.noAbs` is used to + # determine if comparison should be done on a signed or unsigned integer, + # but this variable is never set, resulting in the comparison always + # being done on absolute numbers. + return get_suffix_retrocompat(locale, count.abs) + end + + def get_suffix_retrocompat(locale : String, count : Int) : String + # Get plural form + plural_form = get_plural_form(locale) + rule_numbers = (locale == "or") ? NUMBERS_OR : NUMBERS[plural_form.to_i] + + # Languages with no plural have no suffix + return "" if plural_form.none? + + # Get the index and suffix for this number + # idx = Todo + suffix = rule_numbers[idx] + + # Simple plurals are handled differently in all versions (but v4) + if @simplify_plural_suffix && rule_numbers.size == 2 && rule_numbers[0] == 1 + return "_plural" if (suffix == 2) + return "" if (suffix == 1) + end + + # More complex plurals + # TODO: support `options.prepend` for v2 and v3 + # this.options.prepend && suffix.toString() ? this.options.prepend + suffix.toString() : suffix.toString() + case @version + when 1 then return SUFFIXES_V1[idx] + when 2 then return SUFFIXES_V2[idx] + else return SUFFIXES_V3[idx] + end + end + end end -- cgit v1.2.3 From 67d2635e41313f4bd1fb4259dfc1ce8b923a498f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 18 Nov 2021 22:18:53 +0100 Subject: i18n: Add i18next plural rules and selector --- src/invidious/helpers/i18next.cr | 282 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 281 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr index d8b451a1..0ccb6a3e 100644 --- a/src/invidious/helpers/i18next.cr +++ b/src/invidious/helpers/i18next.cr @@ -190,7 +190,7 @@ module I18next::Plurals return "" if plural_form.none? # Get the index and suffix for this number - # idx = Todo + idx = SuffixIndex.get_index(plural_form, count) suffix = rule_numbers[idx] # Simple plurals are handled differently in all versions (but v4) @@ -209,4 +209,284 @@ module I18next::Plurals end end end + + # ----------------------------- + # Plural functions + # ----------------------------- + + module SuffixIndex + def self.get_index(plural_form : PluralForms, count : Int) : UInt8 + case plural_form + when .single_gt_one? then return (count > 1) ? 1_u8 : 0_u8 + when .single_not_one? then return (count != 1) ? 1_u8 : 0_u8 + when .none? then return 0_u8 + when .dual_slavic? then return dual_slavic(count) + when .special_arabic? then return special_arabic(count) + when .special_czech_slovak? then return special_czech_slovak(count) + when .special_polish_kashubian? then return special_polish_kashubian(count) + when .special_welsh? then return special_welsh(count) + when .special_irish? then return special_irish(count) + when .special_scottish_gaelic? then return special_scottish_gaelic(count) + when .special_icelandic? then return special_icelandic(count) + when .special_javanese? then return special_javanese(count) + when .special_cornish? then return special_cornish(count) + when .special_lithuanian? then return special_lithuanian(count) + when .special_latvian? then return special_latvian(count) + when .special_macedonian? then return special_macedonian(count) + when .special_mandinka? then return special_mandinka(count) + when .special_maltese? then return special_maltese(count) + when .special_romanian? then return special_romanian(count) + when .special_slovenian? then return special_slovenian(count) + when .special_hebrew? then return special_hebrew(count) + else + # default, if nothing matched above + return 0_u8 + end + end + + # Plural form of Slavic languages (E.g: Russian) + # + # Corresponds to i18next rule #4 + # Rule: (n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2) + # + def self.dual_slavic(count : Int) : UInt8 + n_mod_10 = count % 10 + n_mod_100 = count % 100 + + if n_mod_10 == 1 && n_mod_100 != 11 + return 0_u8 + elsif n_mod_10 >= 2 && n_mod_10 <= 4 && (n_mod_100 < 10 || n_mod_100 >= 20) + return 1_u8 + else + return 2_u8 + end + end + + # Plural form for Arabic language + # + # Corresponds to i18next rule #5 + # Rule: (n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 ? 4 : 5) + # + def self.special_arabic(count : Int) : UInt8 + return count.to_u8 if (count == 0 || count == 1 || count == 2) + + n_mod_100 = count % 100 + + return 3_u8 if (n_mod_100 >= 3 && n_mod_100 <= 10) + return 4_u8 if (n_mod_100 >= 11) + return 5_u8 + end + + # Plural form for Czech and Slovak languages + # + # Corresponds to i18next rule #6 + # Rule: ((n==1) ? 0 : (n>=2 && n<=4) ? 1 : 2) + # + def self.special_czech_slovak(count : Int) : UInt8 + return 0_u8 if (count == 1) + return 1_u8 if (count >= 2 && count <= 4) + return 2_u8 + end + + # Plural form for Polish and Kashubian languages + # + # Corresponds to i18next rule #7 + # Rule: (n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2) + # + def self.special_polish_kashubian(count : Int) : UInt8 + return 0_u8 if (count == 1) + + n_mod_10 = count % 10 + n_mod_100 = count % 100 + + if n_mod_10 >= 2 && n_mod_10 <= 4 && (n_mod_100 < 10 || n_mod_100 >= 20) + return 1_u8 + else + return 2_u8 + end + end + + # Plural form for Welsh language + # + # Corresponds to i18next rule #8 + # Rule: ((n==1) ? 0 : (n==2) ? 1 : (n != 8 && n != 11) ? 2 : 3) + # + def self.special_welsh(count : Int) : UInt8 + return 0_u8 if (count == 1) + return 1_u8 if (count == 2) + return 2_u8 if (count != 8 && count != 11) + return 3_u8 + end + + # Plural form for Irish language + # + # Corresponds to i18next rule #10 + # Rule: (n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4) + # + def self.special_irish(count : Int) : UInt8 + return count.to_u8 if (count == 1 || count == 2) + return 2_u8 if (count < 7) + return 3_u8 if (count < 11) + return 4_u8 + end + + # Plural form for Gaelic language + # + # Corresponds to i18next rule #11 + # Rule: ((n==1 || n==11) ? 0 : (n==2 || n==12) ? 1 : (n > 2 && n < 20) ? 2 : 3) + # + def self.special_scottish_gaelic(count : Int) : UInt8 + return 0_u8 if (count == 1 || count == 11) + return 1_u8 if (count == 2 || count == 12) + return 2_u8 if (count > 2 && count < 20) + return 3_u8 + end + + # Plural form for Icelandic language + # + # Corresponds to i18next rule #12 + # Rule: (n%10!=1 || n%100==11) + # + def self.special_icelandic(count : Int) : UInt8 + if (count % 10) != 1 || (count % 100) == 11 + return 1_u8 + else + return 0_u8 + end + end + + # Plural form for Javanese language + # + # Corresponds to i18next rule #13 + # Rule: (n !== 0) + # + def self.special_javanese(count : Int) : UInt8 + return (count != 0) ? 1_u8 : 0_u8 + end + + # Plural form for Cornish language + # + # Corresponds to i18next rule #14 + # Rule: ((n==1) ? 0 : (n==2) ? 1 : (n == 3) ? 2 : 3) + # + def self.special_cornish(count : Int) : UInt8 + return 0_u8 if count == 1 + return 1_u8 if count == 2 + return 2_u8 if count == 3 + return 3_u8 + end + + # Plural form for Lithuanian language + # + # Corresponds to i18next rule #15 + # Rule: (n%10==1 && n%100!=11 ? 0 : n%10>=2 && (n%100<10 || n%100>=20) ? 1 : 2) + # + def self.special_lithuanian(count : Int) : UInt8 + n_mod_10 = count % 10 + n_mod_100 = count % 100 + + if n_mod_10 == 1 && n_mod_100 != 11 + return 0_u8 + elsif n_mod_10 >= 2 && (n_mod_100 < 10 || n_mod_100 >= 20) + return 1_u8 + else + return 2_u8 + end + end + + # Plural form for Latvian language + # + # Corresponds to i18next rule #16 + # Rule: (n%10==1 && n%100!=11 ? 0 : n !== 0 ? 1 : 2) + # + def self.special_latvian(count : Int) : UInt8 + if (count % 10) == 1 && (count % 100) != 11 + return 0_u8 + elsif count != 0 + return 1_u8 + else + return 2_u8 + end + end + + # Plural form for Macedonian language + # + # Corresponds to i18next rule #17 + # Rule: (n==1 || n%10==1 && n%100!=11 ? 0 : 1) + # + def self.special_macedonian(count : Int) : UInt8 + if count == 1 || ((count % 10) == 1 && (count % 100) != 11) + return 0_u8 + else + return 1_u8 + end + end + + # Plural form for Mandinka language + # + # Corresponds to i18next rule #18 + # Rule: (n==0 ? 0 : n==1 ? 1 : 2) + # + def self.special_mandinka(count : Int) : UInt8 + return (count == 0 || count == 1) ? count.to_u8 : 2_u8 + end + + # Plural form for Maltese language + # + # Corresponds to i18next rule #19 + # Rule: (n==1 ? 0 : n==0 || ( n%100>1 && n%100<11) ? 1 : (n%100>10 && n%100<20 ) ? 2 : 3) + # + def self.special_maltese(count : Int) : UInt8 + return 0_u8 if count == 1 + return 1_u8 if count == 0 + + n_mod_100 = count % 100 + return 1_u8 if (n_mod_100 > 1 && n_mod_100 < 11) + return 2_u8 if (n_mod_100 > 10 && n_mod_100 < 20) + return 3_u8 + end + + # Plural form for Romanian language + # + # Corresponds to i18next rule #20 + # Rule: (n==1 ? 0 : (n==0 || (n%100 > 0 && n%100 < 20)) ? 1 : 2) + # + def self.special_romanian(count : Int) : UInt8 + return 0_u8 if count == 1 + return 1_u8 if count == 0 + + n_mod_100 = count % 100 + return 1_u8 if (n_mod_100 > 0 && n_mod_100 < 20) + return 2_u8 + end + + # Plural form for Slovenian language + # + # Corresponds to i18next rule #21 + # Rule: (n%100==1 ? 1 : n%100==2 ? 2 : n%100==3 || n%100==4 ? 3 : 0) + # + def self.special_slovenian(count : Int) : UInt8 + n_mod_100 = count % 100 + return 1_u8 if (n_mod_100 == 1) + return 2_u8 if (n_mod_100 == 2) + return 3_u8 if (n_mod_100 == 3 || n_mod_100 == 4) + return 0_u8 + end + + # Plural form for Hebrew language + # + # Corresponds to i18next rule #22 + # Rule: (n==1 ? 0 : n==2 ? 1 : (n<0 || n>10) && n%10==0 ? 2 : 3) + # + def self.special_hebrew(count : Int) : UInt8 + return 0_u8 if (count == 1) + return 1_u8 if (count == 2) + + if (count < 0 || count > 10) && (count % 10) == 0 + return 2_u8 + else + return 3_u8 + end + end + end end -- cgit v1.2.3 From 4752e16ad2cc2ee717b628032e54e87ad50a4aa0 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 19 Nov 2021 03:48:21 +0100 Subject: i18n: make multiple fixes to i18next plurals --- src/invidious/helpers/i18next.cr | 114 +++++++++++++++++++++++---------------- 1 file changed, 68 insertions(+), 46 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr index 0ccb6a3e..b7506545 100644 --- a/src/invidious/helpers/i18next.cr +++ b/src/invidious/helpers/i18next.cr @@ -5,7 +5,7 @@ module I18next::Plurals # I18next plural forms definition # ----------------------------------- - private enum PluralForms + enum PluralForms # One singular, one plural forms Single_gt_one = 1 # E.g: French Single_not_one = 2 # E.g: English @@ -34,6 +34,7 @@ module I18next::Plurals Special_Romanian = 20 Special_Slovenian = 21 Special_Hebrew = 22 + Special_Odia = 23 end private PLURAL_SETS = { @@ -75,13 +76,15 @@ module I18next::Plurals "mk" => PluralForms::Special_Macedonian, "mnk" => PluralForms::Special_Mandinka, "mt" => PluralForms::Special_Maltese, + "or" => PluralForms::Special_Odia, "pl" => PluralForms::Special_Polish_Kashubian, "ro" => PluralForms::Special_Romanian, "sk" => PluralForms::Special_Czech_Slovak, "sl" => PluralForms::Special_Slovenian, } - # The array indices matches the PluralForms enum above + # These are the v1 and v2 compatible suffixes. + # The array indices matches the PluralForms enum above. private NUMBERS = [ [1, 2], # 1 [1, 2], # 2 @@ -105,67 +108,58 @@ module I18next::Plurals [1, 2, 20], # 20 [5, 1, 2, 3], # 21 [1, 2, 20, 21], # 22 + [2, 1], # 23 (Odia) ] - # "or" () - private NUMBERS_OR = [2, 1] - # ----------------------------------- # I18next plural resolver class # ----------------------------------- + RESOLVER = Resolver.new + class Resolver - @@forms : Hash(String, PluralForms) = init_rules() - @@version : UInt8 = 3 + private property forms = {} of String => PluralForms + property version : UInt8 = 3 # Options property simplify_plural_suffix : Bool = true - # Suffixes - SUFFIXES_V1 = { - "", - "_plural_1", - "_plural_2", - "_plural_3", - "_plural_11", - "_plural_100", - } - SUFFIXES_V2 = {"_0", "_1", "_2", "_3", "_11", "_100"} - SUFFIXES_V3 = {"_0", "_1", "_2", "_3", "_4", "_5"} - - def initialize(version : UInt8 = 3) + def initialize(version : Int = 3) # Sanity checks # V4 isn't supported, as it requires a full CLDR database. if version > 4 || version == 0 raise "Invalid i18next version: v#{version}." elsif version == 4 # Logger.error("Unsupported i18next version: v4. Falling back to v3") - @@version = 3 + @version = 3_u8 else - @@version = version + @version = version.to_u8 end + + self.init_rules end - def self.init_rules + def init_rules # : Hash(String, PluralForms) + # Init + # forms = {} of String => PluralForms + # Look into sets PLURAL_SETS.each do |form, langs| - langs.each { |lang| @@forms[lang] = form } + langs.each { |lang| self.forms[lang] = form } end # Add plurals from the "singles" set - @@forms.merge!(PLURAL_SINGLES) + self.forms.merge!(PLURAL_SINGLES) end def get_plural_form(locale : String) : PluralForms - # Extract the ISO 639-1 or 639-2 code from an RFC 5646 - # language code, except for pt-BR which needs to be kept as-is. - if locale.starts_with?("pt-BR") - locale = "pt-BR" - else + # Extract the ISO 639-1 or 639-2 code from an RFC 5646 language code, + # except for pt-BR and pt-PT which needs to be kept as-is. + if !locale.matches?(/^pt-(BR|PT)$/) locale = locale.split('-')[0] end - return @@forms[locale] if @@forms[locale]? + return self.forms[locale] if self.forms[locale]? # If nothing was found, then use the most common form, i.e # one singular and one plural, as in english. Not perfect, @@ -181,32 +175,48 @@ module I18next::Plurals return get_suffix_retrocompat(locale, count.abs) end - def get_suffix_retrocompat(locale : String, count : Int) : String + # Emulate the `rule.numbers.size == 2 && rule.numbers[0] == 1` check + # from original i18next code + private def is_simple_plural(form : PluralForms) : Bool + case form + when .single_gt_one? then return true + when .single_not_one? then return true + when .special_icelandic? then return true + when .special_macedonian? then return true + else + return false + end + end + + private def get_suffix_retrocompat(locale : String, count : Int) : String # Get plural form plural_form = get_plural_form(locale) - rule_numbers = (locale == "or") ? NUMBERS_OR : NUMBERS[plural_form.to_i] - # Languages with no plural have no suffix - return "" if plural_form.none? + # Languages with no plural have the "_0" suffix + return "_0" if plural_form.none? # Get the index and suffix for this number idx = SuffixIndex.get_index(plural_form, count) - suffix = rule_numbers[idx] # Simple plurals are handled differently in all versions (but v4) - if @simplify_plural_suffix && rule_numbers.size == 2 && rule_numbers[0] == 1 - return "_plural" if (suffix == 2) - return "" if (suffix == 1) + if @simplify_plural_suffix && is_simple_plural(plural_form) + return (idx == 1) ? "_plural" : "" end # More complex plurals - # TODO: support `options.prepend` for v2 and v3 + # TODO: support v1 and v2 + # TODO: support `options.prepend` (v2 and v3) # this.options.prepend && suffix.toString() ? this.options.prepend + suffix.toString() : suffix.toString() - case @version - when 1 then return SUFFIXES_V1[idx] - when 2 then return SUFFIXES_V2[idx] - else return SUFFIXES_V3[idx] - end + # + # case @version + # when 1 + # suffix = SUFFIXES_V1_V2[plural_form.to_i][idx] + # return (suffix == 1) ? "" : return "_plural_#{suffix}" + # when 2 + # return "_#{suffix}" + # else # v3 + return "_#{idx}" + # end end end @@ -238,6 +248,7 @@ module I18next::Plurals when .special_romanian? then return special_romanian(count) when .special_slovenian? then return special_slovenian(count) when .special_hebrew? then return special_hebrew(count) + when .special_odia? then return special_odia(count) else # default, if nothing matched above return 0_u8 @@ -324,7 +335,8 @@ module I18next::Plurals # Rule: (n==1 ? 0 : n==2 ? 1 : n<7 ? 2 : n<11 ? 3 : 4) # def self.special_irish(count : Int) : UInt8 - return count.to_u8 if (count == 1 || count == 2) + return 0_u8 if (count == 1) + return 1_u8 if (count == 2) return 2_u8 if (count < 7) return 3_u8 if (count < 11) return 4_u8 @@ -488,5 +500,15 @@ module I18next::Plurals return 3_u8 end end + + # Plural form for Odia ("or") language + # + # This one is a bit special. It should use rule #2 (like english) + # but the "numbers" (suffixes?) it has are inverted, so we'll make a + # special rule for it. + # + def self.special_odia(count : Int) : UInt8 + return (count == 1) ? 0_u8 : 1_u8 + end end end -- cgit v1.2.3 From 7bb1471207a6dd30bae9466497e940ccc6057196 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 21 Dec 2021 23:10:03 +0100 Subject: i18n: Add dedicated function for counts translation --- src/invidious/helpers/i18n.cr | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index fd3ddbad..316e5cda 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -107,6 +107,36 @@ def translate(locale : String?, key : String, text : String | Nil = nil) : Strin return translation end +def translate_count(locale : String, key : String, count : Int) : String + # Fallback on english if locale doesn't exist + locale = "en-US" if !LOCALES.has_key?(locale) + + # Retrieve suffix + suffix = I18next::Plurals::RESOLVER.get_suffix(locale, count) + plural_key = key + suffix + + if LOCALES[locale].has_key?(plural_key) + translation = LOCALES[locale][plural_key].as_s + else + # Try #1: Fallback to singular in the same locale + singular_suffix = I18next::Plurals::RESOLVER.get_suffix(locale, 1) + + if LOCALES[locale].has_key?(key + singular_suffix) + translation = LOCALES[locale][key + singular_suffix].as_s + else + # Try #2: Fallback to english (or return key we're already in english) + if locale == "en-US" + LOGGER.warn("i18n: Missing translation key \"#{key}\"") + return key + end + + translation = translate_count("en-US", key, count) + end + end + + return translation.gsub("{{count}}", count.to_s) +end + def translate_bool(locale : String?, translation : Bool) case translation when true -- cgit v1.2.3 From 692f4e5be2c80556e84fa618eb8124083da24161 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 22 Dec 2021 00:07:20 +0100 Subject: i18n: Use plurals for year/month/day/etc... --- locales/en-US.json | 42 ++++++++++++++---------------------------- locales/fa.json | 35 +++++++---------------------------- locales/fr.json | 42 ++++++++++++++---------------------------- locales/id.json | 35 +++++++---------------------------- locales/it.json | 42 ++++++++++++++---------------------------- locales/ja.json | 35 +++++++---------------------------- locales/ko.json | 35 +++++++---------------------------- locales/pt-BR.json | 42 ++++++++++++++---------------------------- locales/pt-PT.json | 42 ++++++++++++++---------------------------- locales/pt.json | 42 ++++++++++++++---------------------------- locales/zh-CN.json | 35 +++++++---------------------------- locales/zh-TW.json | 35 +++++++---------------------------- src/invidious/helpers/utils.cr | 16 +++++++--------- 13 files changed, 133 insertions(+), 345 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 94aac89e..166143ac 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -359,34 +359,20 @@ "Yiddish": "Yiddish", "Yoruba": "Yoruba", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` year", - "": "`x` years" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` month", - "": "`x` months" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` week", - "": "`x` weeks" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` day", - "": "`x` days" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hour", - "": "`x` hours" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minute", - "": "`x` minutes" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` second", - "": "`x` seconds" - }, + "generic_count_years": "{{count}} year", + "generic_count_years_plural": "{{count}} years", + "generic_count_months": "{{count}} month", + "generic_count_months_plural": "{{count}} months", + "generic_count_weeks": "{{count}} week", + "generic_count_weeks_plural": "{{count}} weeks", + "generic_count_days": "{{count}} day", + "generic_count_days_plural": "{{count}} days", + "generic_count_hours": "{{count}} hour", + "generic_count_hours_plural": "{{count}} hours", + "generic_count_minutes": "{{count}} minute", + "generic_count_minutes_plural": "{{count}} minutes", + "generic_count_seconds": "{{count}} second", + "generic_count_seconds_plural": "{{count}} seconds", "Fallback comments: ": "Fallback comments: ", "Popular": "Popular", "Search": "Search", diff --git a/locales/fa.json b/locales/fa.json index 1f723a63..d8df2b4f 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -340,34 +340,13 @@ "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` ثانیه" - }, + "generic_count_years_0": "{{count}} سال", + "generic_count_months_0": "{{count}} ماه", + "generic_count_weeks_0": "{{count}} هفته", + "generic_count_days_0": "{{count}} روز", + "generic_count_hours_0": "{{count}} ساعت", + "generic_count_minutes_0": "{{count}} دقیقه", + "generic_count_seconds_0": "{{count}} ثانیه", "Fallback comments: ": "نظرات عقب گرد: ", "Popular": "محبوب", "Search": "جستجو", diff --git a/locales/fr.json b/locales/fr.json index 5ebd6f70..f9975a6b 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -340,34 +340,20 @@ "Yiddish": "Yiddish", "Yoruba": "Yoruba", "Zulu": "Zoulou", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` an", - "": "`x` ans" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mois", - "": "`x` mois" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semaine", - "": "`x` semaines" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jour", - "": "`x` jours" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` heure", - "": "`x` heures" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minute", - "": "`x` minutes" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` seconde", - "": "`x` secondes" - }, + "generic_count_years": "{{count}} an", + "generic_count_years_plural": "{{count}} ans", + "generic_count_months": "{{count}} mois", + "generic_count_months_plural": "{{count}} mois", + "generic_count_weeks": "{{count}} semaine", + "generic_count_weeks_plural": "{{count}} semaines", + "generic_count_days": "{{count}} jour", + "generic_count_days_plural": "{{count}} jours", + "generic_count_hours": "{{count}} heure", + "generic_count_hours_plural": "{{count}} heures", + "generic_count_minutes": "{{count}} minute", + "generic_count_minutes_plural": "{{count}} minutes", + "generic_count_seconds": "{{count}} seconde", + "generic_count_seconds_plural": "{{count}} secondes", "Fallback comments: ": "Commentaires alternatifs : ", "Popular": "Populaire", "Search": "Rechercher", diff --git a/locales/id.json b/locales/id.json index b3918955..78f5e773 100644 --- a/locales/id.json +++ b/locales/id.json @@ -340,34 +340,13 @@ "Yiddish": "Bahasa Yiddi", "Yoruba": "Bahasa Yoruba", "Zulu": "Bahasa Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tahun", - "": "`x` tahun" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` bulan", - "": "`x` bulan" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pekan", - "": "`x` pekan" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hari", - "": "`x` hari" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` jam", - "": "`x` jam" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` menit", - "": "`x` menit" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` detik", - "": "`x` detik" - }, + "generic_count_years_0": "{{count}} tahun", + "generic_count_months_0": "{{count}} bulan", + "generic_count_weeks_0": "{{count}} pekan", + "generic_count_days_0": "{{count}} hari", + "generic_count_hours_0": "{{count}} jam", + "generic_count_minutes_0": "{{count}} menit", + "generic_count_seconds_0": "{{count}} detik", "Fallback comments: ": "Komentar alternatif: ", "Popular": "Populer", "Search": "Cari", diff --git a/locales/it.json b/locales/it.json index a43e1a49..befdd665 100644 --- a/locales/it.json +++ b/locales/it.json @@ -330,34 +330,20 @@ "Yiddish": "Yiddish", "Yoruba": "Yoruba", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` anno", - "": "`x` anni" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mese", - "": "`x` mesi" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` settimana", - "": "`x` settimane" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` giorno", - "": "`x` giorni" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ora", - "": "`x` ore" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto", - "": "`x` minuti" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` secondo", - "": "`x` secondi" - }, + "generic_count_years": "{{count}} anno", + "generic_count_years_plural": "{{count}} anni", + "generic_count_months": "{{count}} mese", + "generic_count_months_plural": "{{count}} mesi", + "generic_count_weeks": "{{count}} settimana", + "generic_count_weeks_plural": "{{count}} settimane", + "generic_count_days": "{{count}} giorno", + "generic_count_days_plural": "{{count}} giorni", + "generic_count_hours": "{{count}} ora", + "generic_count_hours_plural": "{{count}} ore", + "generic_count_minutes": "{{count}} minuto", + "generic_count_minutes_plural": "{{count}} minuti", + "generic_count_seconds": "{{count}} secondo", + "generic_count_seconds_plural": "{{count}} secondi", "Fallback comments: ": "Commenti alternativi: ", "Popular": "Popolare", "Search": "Cerca", diff --git a/locales/ja.json b/locales/ja.json index bf858f1f..7423d2ca 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -340,34 +340,13 @@ "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`秒" - }, + "generic_count_years_0": "{{count}}年", + "generic_count_months_0": "{{count}}ヶ月", + "generic_count_weeks_0": "{{count}}週", + "generic_count_days_0": "{{count}}日", + "generic_count_hours_0": "{{count}}時間", + "generic_count_minutes_0": "{{count}}分", + "generic_count_seconds_0": "{{count}}秒", "Fallback comments: ": "フォールバック時のコメント: ", "Popular": "人気", "Search": "検索", diff --git a/locales/ko.json b/locales/ko.json index b96b3c0b..96fd41ff 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -336,36 +336,15 @@ "Scottish Gaelic": "스코틀랜드 게일어", "Popular": "인기", "Fallback comments: ": "대체 댓글: ", - "`x` seconds": { - "": "`x` 초", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 초" - }, "Swahili": "스와힐리어", "Sundanese": "순다어", - "`x` hours": { - "": "`x` 시", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 시" - }, - "`x` minutes": { - "": "`x` 분", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 분" - }, - "`x` days": { - "": "`x` 일", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 일" - }, - "`x` weeks": { - "": "`x` 주", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 주" - }, - "`x` months": { - "": "`x` 월", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 월" - }, - "`x` years": { - "": "`x` 년", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 년" - }, + "generic_count_years_0": "{{count}} 년", + "generic_count_months_0": "{{count}} 월", + "generic_count_weeks_0": "{{count}} 주", + "generic_count_days_0": "{{count}} 일", + "generic_count_hours_0": "{{count}} 시", + "generic_count_minutes_0": "{{count}} 분", + "generic_count_seconds_0": "{{count}} 초", "Zulu": "줄루어", "Yoruba": "요루바어", "Yiddish": "이디시어", diff --git a/locales/pt-BR.json b/locales/pt-BR.json index 6baa2c0d..01407669 100644 --- a/locales/pt-BR.json +++ b/locales/pt-BR.json @@ -340,34 +340,20 @@ "Yiddish": "Iídiche", "Yoruba": "Iorubá", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ano", - "": "`x` anos" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mês", - "": "`x` meses" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` semana", - "": "`x` semanas" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dia", - "": "`x` dia" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hora", - "": "`x` horas" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto", - "": "`x` minutos" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundo", - "": "`x` segundos" - }, + "generic_count_years": "{{count}} ano", + "generic_count_years_plural": "{{count}} anos", + "generic_count_months": "{{count}} mês", + "generic_count_months_plural": "{{count}} meses", + "generic_count_weeks": "{{count}} semana", + "generic_count_weeks_plural": "{{count}} semanas", + "generic_count_days": "{{count}} dia", + "generic_count_days_plural": "{{count}} dia", + "generic_count_hours": "{{count}} hora", + "generic_count_hours_plural": "{{count}} horas", + "generic_count_minutes": "{{count}} minuto", + "generic_count_minutes_plural": "{{count}} minutos", + "generic_count_seconds": "{{count}} segundo", + "generic_count_seconds_plural": "{{count}} segundos", "Fallback comments: ": "Comentários alternativos: ", "Popular": "Populares", "Search": "Procurar", diff --git a/locales/pt-PT.json b/locales/pt-PT.json index f3952f12..83b59ab5 100644 --- a/locales/pt-PT.json +++ b/locales/pt-PT.json @@ -340,34 +340,20 @@ "Yiddish": "Iídiche", "Yoruba": "Ioruba", "Zulu": "Zulu", - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ano", - "": "`x` anos" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mês", - "": "`x` meses" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` seman", - "": "`x` semanas" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dia", - "": "`x` dias" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hora", - "": "`x` horas" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto", - "": "`x` minutos" - }, - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundo", - "": "`x` segundos" - }, + "generic_count_years": "{{count}} ano", + "generic_count_years_plural": "{{count}} anos", + "generic_count_months": "{{count}} mês", + "generic_count_months_plural": "{{count}} meses", + "generic_count_weeks": "{{count}} seman", + "generic_count_weeks_plural": "{{count}} semanas", + "generic_count_days": "{{count}} dia", + "generic_count_days_plural": "{{count}} dias", + "generic_count_hours": "{{count}} hora", + "generic_count_hours_plural": "{{count}} horas", + "generic_count_minutes": "{{count}} minuto", + "generic_count_minutes_plural": "{{count}} minutos", + "generic_count_seconds": "{{count}} segundo", + "generic_count_seconds_plural": "{{count}} segundos", "Fallback comments: ": "Comentários alternativos: ", "Popular": "Popular", "Search": "Pesquisar", diff --git a/locales/pt.json b/locales/pt.json index 1976fe38..065170fb 100644 --- a/locales/pt.json +++ b/locales/pt.json @@ -46,34 +46,20 @@ "Default": "Predefinido", "Top": "Destaques", "Search": "Pesquisar", - "`x` seconds": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` segundo", - "": "`x` segundos" - }, - "`x` minutes": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` minuto", - "": "`x` minutos" - }, - "`x` hours": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` hora", - "": "`x` horas" - }, - "`x` days": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` dia", - "": "`x` dias" - }, - "`x` weeks": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` seman", - "": "`x` semanas" - }, - "`x` months": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` mês", - "": "`x` meses" - }, - "`x` years": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ano", - "": "`x` anos" - }, + "generic_count_years": "{{count}} segundo", + "generic_count_years_plural": "{{count}} segundos", + "generic_count_months": "{{count}} minuto", + "generic_count_months_plural": "{{count}} minutos", + "generic_count_weeks": "{{count}} hora", + "generic_count_weeks_plural": "{{count}} horas", + "generic_count_days": "{{count}} dia", + "generic_count_days_plural": "{{count}} dias", + "generic_count_hours": "{{count}} seman", + "generic_count_hours_plural": "{{count}} semanas", + "generic_count_minutes": "{{count}} mês", + "generic_count_minutes_plural": "{{count}} meses", + "generic_count_seconds": "{{count}} ano", + "generic_count_seconds_plural": "{{count}} anos", "Chinese (Traditional)": "Chinês (tradicional)", "Chinese (Simplified)": "Chinês (simplificado)", "Could not pull trending pages.": "Não foi possível obter as páginas de tendências.", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index ed5d82ce..6108a680 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -340,34 +340,13 @@ "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` 秒" - }, + "generic_count_years_0": "{{count}} 年", + "generic_count_months_0": "{{count}} 月", + "generic_count_weeks_0": "{{count}} 周", + "generic_count_days_0": "{{count}} 天", + "generic_count_hours_0": "{{count}} 小时", + "generic_count_minutes_0": "{{count}} 分钟", + "generic_count_seconds_0": "{{count}} 秒", "Fallback comments: ": "后备评论: ", "Popular": "热门频道", "Search": "搜索", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index aad51069..d3580c4d 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -340,34 +340,13 @@ "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` 秒" - }, + "generic_count_years_0": "{{count}} 年", + "generic_count_months_0": "{{count}} 月", + "generic_count_weeks_0": "{{count}} 週", + "generic_count_days_0": "{{count}} 天", + "generic_count_hours_0": "{{count}} 小時", + "generic_count_minutes_0": "{{count}} 分鐘", + "generic_count_seconds_0": "{{count}} 秒", "Fallback comments: ": "汰退留言: ", "Popular": "熱門頻道", "Search": "搜尋", diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 8453d605..09181c10 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -123,22 +123,20 @@ def recode_date(time : Time, locale) span = Time.utc - time if span.total_days > 365.0 - span = translate(locale, "`x` years", (span.total_days.to_i // 365).to_s) + return translate_count(locale, "generic_count_years", span.total_days.to_i // 365) elsif span.total_days > 30.0 - span = translate(locale, "`x` months", (span.total_days.to_i // 30).to_s) + return translate_count(locale, "generic_count_months", span.total_days.to_i // 30) elsif span.total_days > 7.0 - span = translate(locale, "`x` weeks", (span.total_days.to_i // 7).to_s) + return translate_count(locale, "generic_count_weeks", span.total_days.to_i // 7) elsif span.total_hours > 24.0 - span = translate(locale, "`x` days", (span.total_days.to_i).to_s) + return translate_count(locale, "generic_count_days", span.total_days.to_i) elsif span.total_minutes > 60.0 - span = translate(locale, "`x` hours", (span.total_hours.to_i).to_s) + return translate_count(locale, "generic_count_hours", span.total_hours.to_i) elsif span.total_seconds > 60.0 - span = translate(locale, "`x` minutes", (span.total_minutes.to_i).to_s) + return translate_count(locale, "generic_count_minutes", span.total_minutes.to_i) else - span = translate(locale, "`x` seconds", (span.total_seconds.to_i).to_s) + return translate_count(locale, "generic_count_seconds", span.total_seconds.to_i) end - - return span end def number_with_separator(number) -- cgit v1.2.3 From 5bb2cb7d71b3e44e7754a4e06e45c831d30211fc Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 27 Dec 2021 15:17:50 +0100 Subject: i18n: Use plurals for video/view/subscriber/subscription counts --- locales/en-US.json | 30 ++++++++++------------------ locales/fa.json | 25 +++++------------------ locales/fr.json | 30 ++++++++++------------------ locales/id.json | 25 +++++------------------ locales/it.json | 30 ++++++++++------------------ locales/ja.json | 25 +++++------------------ locales/ko.json | 25 +++++------------------ locales/vi.json | 9 ++------- locales/zh-CN.json | 25 +++++------------------ locales/zh-TW.json | 25 +++++------------------ src/invidious/channels/community.cr | 2 +- src/invidious/helpers/i18n.cr | 19 ++++++++++++++++-- src/invidious/views/components/item.ecr | 10 +++++----- src/invidious/views/edit_playlist.ecr | 2 +- src/invidious/views/feeds/history.ecr | 4 ++-- src/invidious/views/playlist.ecr | 4 ++-- src/invidious/views/subscription_manager.ecr | 2 +- src/invidious/views/watch.ecr | 2 +- 18 files changed, 92 insertions(+), 202 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 166143ac..9d3a70d2 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -1,16 +1,14 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscriber", - "": "`x` subscribers" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", - "": "`x` videos" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist", - "": "`x` playlists" - }, + "generic_views_count": "{{count}} view", + "generic_views_count_plural": "{{count}} views", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} videos", + "generic_playlists_count": "{{count}} playlist", + "generic_playlists_count_plural": "{{count}} playlists", + "generic_subscribers_count": "{{count}} subscriber", + "generic_subscribers_count_plural": "{{count}} subscribers", + "generic_subscriptions_count": "{{count}} subscription", + "generic_subscriptions_count_plural": "{{count}} subscriptions", "LIVE": "LIVE", "Shared `x` ago": "Shared `x` ago", "Unsubscribe": "Unsubscribe", @@ -146,10 +144,6 @@ "Subscription manager": "Subscription manager", "Token manager": "Token manager", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscription", - "": "`x` subscriptions" - }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", "": "`x` tokens" @@ -195,10 +189,6 @@ "Whitelisted regions: ": "Whitelisted regions: ", "Blacklisted regions: ": "Blacklisted regions: ", "Shared `x`": "Shared `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` view", - "": "`x` views" - }, "Premieres in `x`": "Premieres in `x`", "Premieres `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.": "Hi! Looks like you have JavaScript turned off. Click here to view comments, keep in mind they may take a bit longer to load.", diff --git a/locales/fa.json b/locales/fa.json index d8df2b4f..22ca416c 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -1,16 +1,9 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` دنبال کننده", - "": "`x` دنبال کننده" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` ویدئو", - "": "`x` ویدئو" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` فهرست پخش", - "": "`x` فهرست پخش" - }, + "generic_views_count_0": "{{count}} بازدید", + "generic_videos_count_0": "{{count}} ویدئو", + "generic_playlists_count_0": "{{count}} فهرست پخش", + "generic_subscribers_count_0": "{{count}} دنبال کننده", + "generic_subscriptions_count_0": "{{count}} اشتراک ها", "LIVE": "زنده", "Shared `x` ago": "`x` پیش به اشتراک گذاشته شده", "Unsubscribe": "لغو اشتراک", @@ -127,10 +120,6 @@ "Subscription manager": "مدیریت اشتراک", "Token manager": "مدیر توکن", "Token": "توکن", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` اشتراک ها", - "": "`x` اشتراک ها" - }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` توکن ها", "": "`x` توکن ها" @@ -176,10 +165,6 @@ "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.": "سلام! مثل اینکه تو جاوا اسکریپت رو خاموش کرده ای. اینجا کلیک کن تا نظرات را ببینی، این رو یادت باشه که ممکنه بارگذاری اونها کمی طول بکشه.", diff --git a/locales/fr.json b/locales/fr.json index f9975a6b..d14a20ac 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -1,16 +1,14 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonné", - "": "`x` abonnés" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vidéo", - "": "`x` vidéos" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` liste de lecture", - "": "`x` listes de lecture" - }, + "generic_views_count": "{{count}} vue", + "generic_views_count_plural":"{{count}} vues", + "generic_videos_count": "{{count}} vidéo", + "generic_videos_count_plural": "{{count}} vidéos", + "generic_playlists_count": "{{count}} liste de lecture", + "generic_playlists_count_plural": "{{count}} listes de lecture", + "generic_subscribers_count": "{{count}} abonné", + "generic_subscribers_count_plural": "{{count}} abonnés", + "generic_subscriptions_count": "{{count}} abonnement", + "generic_subscriptions_count_plural": "{{count}} abonnements", "LIVE": "EN DIRECT", "Shared `x` ago": "Ajoutée il y a `x`", "Unsubscribe": "Se désabonner", @@ -127,10 +125,6 @@ "Subscription manager": "Gestionnaire d'abonnement", "Token manager": "Gestionnaire de token", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` abonnements", - "": "`x` abonnements" - }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", "": "`x` tokens" @@ -176,10 +170,6 @@ "Whitelisted regions: ": "Régions sur liste blanche : ", "Blacklisted regions: ": "Régions sur liste noire : ", "Shared `x`": "Ajoutée le `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` vues", - "": "`x` vues" - }, "Premieres in `x`": "Première dans `x`", "Premieres `x`": "Première le `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.": "Il semblerait que JavaScript soit désactivé. Cliquez ici pour voir les commentaires, mais gardez à l'esprit que le chargement peut prendre plus de temps.", diff --git a/locales/id.json b/locales/id.json index 78f5e773..949cc69a 100644 --- a/locales/id.json +++ b/locales/id.json @@ -1,16 +1,9 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` pelanggan", - "": "`x` pelanggan" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", - "": "`x` video" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` daftar putar", - "": "`x` daftar putar" - }, + "generic_views_count_0": "{{count}} tampilan", + "generic_videos_count_0": "{{count}} video", + "generic_playlists_count_0": "{{count}} daftar putar", + "generic_subscribers_count_0": "{{count}} pelanggan", + "generic_subscriptions_count_0": "{{count}} langganan", "LIVE": "SIARAN LANGSUNG", "Shared `x` ago": "Dibagikan `x` yang lalu", "Unsubscribe": "Batal Langganan", @@ -127,10 +120,6 @@ "Subscription manager": "Pengatur langganan", "Token manager": "Pengatur token", "Token": "Token", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` langganan", - "": "`x` langganan" - }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` token", "": "`x` token" @@ -176,10 +165,6 @@ "Whitelisted regions: ": "Wilayah daftar-putih: ", "Blacklisted regions: ": "Wilayah daftar-hitam: ", "Shared `x`": "Berbagi `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` tampilan", - "": "`x` tampilan" - }, "Premieres in `x`": "Tayang dalam `x`", "Premieres `x`": "Tayang `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.": "Hai! Kelihatannya JavaScript kamu dimatikan. Klik di sini untuk melihat komentar, perlu diingat hal ini mungkin membutuhkan waktu sedikit lebih lama untuk dimuat.", diff --git a/locales/it.json b/locales/it.json index befdd665..2722e7bb 100644 --- a/locales/it.json +++ b/locales/it.json @@ -1,16 +1,10 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscritto", - "": "`x` iscritti" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video", - "": "`x` video" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` playlist", - "": "`x` playlist" - }, + "generic_subscribers_count": "{{count}} iscritto", + "generic_subscribers_count_plural": "{{count}} iscritti", + "generic_videos_count": "{{count}} video", + "generic_videos_count_plural": "{{count}} video", + "generic_playlists_count": "{{count}} playlist", + "generic_playlists_count_plural": "{{count}} playlist", "LIVE": "IN DIRETTA", "Shared `x` ago": "Condiviso `x` fa", "Unsubscribe": "Disiscriviti", @@ -122,10 +116,8 @@ "Subscription manager": "Gestione delle iscrizioni", "Token manager": "Gestione dei gettoni", "Token": "Gettone", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` iscrizione", - "": "`x` iscrizioni" - }, + "generic_subscriptions_count": "{{count}} iscrizione", + "generic_subscriptions_count_plural": "{{count}} iscrizioni", "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` gettone", "": "`x` gettoni" @@ -166,10 +158,8 @@ "Whitelisted regions: ": "Regioni in lista bianca: ", "Blacklisted regions: ": "Regioni in lista nera: ", "Shared `x`": "Condiviso `x`", - "`x` views": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` visualizzazione", - "": "`x` visualizzazioni" - }, + "generic_views_count": "{{count}} visualizzazione", + "generic_views_count_plural": "{{count}} visualizzazioni", "Premieres in `x`": "In anteprima in `x`", "Premieres `x`": "In anteprima `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.": "Ciao! Sembra che tu abbia disattivato JavaScript. Clicca qui per visualizzare i commenti. Considera che potrebbe volerci più tempo.", diff --git a/locales/ja.json b/locales/ja.json index 7423d2ca..52406f0d 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -1,16 +1,9 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 人の登録者", - "": "`x` 人の登録者" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の動画", - "": "`x` 個の動画" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の再生リスト", - "": "`x` 個の再生リスト" - }, + "generic_views_count_0": "{{count}} 回視聴", + "generic_videos_count_0": "{{count}} 個の動画", + "generic_playlists_count_0": "{{count}} 個の再生リスト", + "generic_subscribers_count_0": "{{count}} 人の登録者", + "generic_subscriptions_count_0": "{{count}} 個の登録チャンネル", "LIVE": "ライブ", "Shared `x` ago": "`x`前に共有", "Unsubscribe": "登録解除", @@ -127,10 +120,6 @@ "Subscription manager": "登録チャンネルマネージャー", "Token manager": "トークンマネージャー", "Token": "トークン", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個の登録チャンネル", - "": "`x` 個の登録チャンネル" - }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個のトークン", "": "`x` 個のトークン" @@ -176,10 +165,6 @@ "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 を無効にしているのかな?ここをクリックしてコメントを見れるけど、読み込みには少し時間がかかることがあるのを覚えておいてね。", diff --git a/locales/ko.json b/locales/ko.json index 96fd41ff..16cf59b9 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -81,18 +81,11 @@ "Subscribe": "구독", "Unsubscribe": "구독 취소", "LIVE": "실시간", - "`x` playlists": { - "": "`x` 재생목록", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 재생목록" - }, - "`x` videos": { - "": "`x` 동영상", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 동영상" - }, - "`x` subscribers": { - "": "`x` 구독자", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 구독자" - }, + "generic_views_count_0": "{{count}} 조회수", + "generic_videos_count_0": "{{count}} 동영상", + "generic_playlists_count_0": "{{count}} 재생목록", + "generic_subscribers_count_0": "{{count}} 구독자", + "generic_subscriptions_count_0": "{{count}} 구독", "playlist": "재생목록", "Korean": "한국어", "Japanese": "일본어", @@ -158,10 +151,6 @@ "": "`x` 토큰", "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 토큰" }, - "`x` subscriptions": { - "": "`x` 구독", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 구독" - }, "Token": "토큰", "Token manager": "토큰 관리자", "Subscription manager": "구독 관리자", @@ -300,10 +289,6 @@ "Shared `x`": "공유된 `x`", "Whitelisted regions: ": "차단되지 않은 지역: ", "views": "조회수", - "`x` views": { - "": "`x` 조회수", - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 조회수" - }, "Please log in": "로그인하세요", "Password cannot be longer than 55 characters": "비밀번호는 55자 이하여야 합니다", "Password cannot be empty": "비밀번호는 비워둘 수 없습니다", diff --git a/locales/vi.json b/locales/vi.json index e433ad55..a8550686 100644 --- a/locales/vi.json +++ b/locales/vi.json @@ -1,11 +1,6 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` subscribers", - "": "`x` subscribers" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` video" - }, + "generic_videos_count_0": "{{count}} video", + "generic_subscribers_count_0": "{{count}} subscribers", "LIVE": "TRỰC TIẾP", "Shared `x` ago": "Đã chia sẻ` x` trước", "Unsubscribe": "Hủy đăng ký", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 6108a680..f3a6bd98 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -1,16 +1,9 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 位订阅者", - "": "`x` 位订阅者" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个视频", - "": "`x` 个视频" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个播放列表", - "": "`x` 个播放列表" - }, + "generic_views_count_0": "{{count}} 播放", + "generic_videos_count_0": "{{count}} 个视频", + "generic_playlists_count_0": "{{count}} 个播放列表", + "generic_subscribers_count_0": "{{count}} 位订阅者", + "generic_subscriptions_count_0": "{{count}} 个订阅", "LIVE": "直播", "Shared `x` ago": "`x` 前分享", "Unsubscribe": "取消订阅", @@ -127,10 +120,6 @@ "Subscription manager": "订阅管理器", "Token manager": "令牌管理器", "Token": "令牌", - "`x` subscriptions": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个订阅", - "": "`x` 个订阅" - }, "`x` tokens": { "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 个令牌", "": "`x` 个令牌" @@ -176,10 +165,6 @@ "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。点击这里阅读评论。注意它们加载的时间可能会稍长。", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index d3580c4d..1954e34a 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -1,16 +1,9 @@ { - "`x` subscribers": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 個訂閱者", - "": "`x` 個訂閱者" - }, - "`x` videos": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 部影片", - "": "`x` 部影片" - }, - "`x` playlists": { - "([^.,0-9]|^)1([^.,0-9]|$)": "`x` 播放清單", - "": "`x` 播放清單" - }, + "generic_views_count_0": "{{count}} 次檢視", + "generic_videos_count_0": "{{count}} 部影片", + "generic_playlists_count_0": "{{count}} 播放清單", + "generic_subscribers_count_0": "{{count}} 個訂閱者", + "generic_subscriptions_count_0": "{{count}} 個訂閱", "LIVE": "直播", "Shared `x` ago": "`x` 前分享", "Unsubscribe": "取消訂閱", @@ -127,10 +120,6 @@ "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` 個存取金鑰" @@ -176,10 +165,6 @@ "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 關閉了。點擊這裡以檢視留言,請注意,它們可能需要比較長的時間載入。", diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 9a50f893..4701ecbd 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -158,7 +158,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64 json.field "viewCount", view_count - json.field "viewCountText", translate(locale, "`x` views", number_to_short_text(view_count)) + json.field "viewCountText", translate_count(locale, "generic_views_count", view_count, NumberFormatting::Short) end when .has_key?("backstageImageRenderer") attachment = attachment["backstageImageRenderer"] diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 316e5cda..c7b63f04 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -54,6 +54,14 @@ CONTENT_REGIONS = { "YE", "ZA", "ZW", } +# Enum for the different types of number formats +enum NumberFormatting + None # Print the number as-is + Separator # Use a separator for thousands + Short # Use short notation (k/M/B) + HtmlSpan # Surround with +end + def load_all_locales locales = {} of String => Hash(String, JSON::Any) @@ -107,7 +115,7 @@ def translate(locale : String?, key : String, text : String | Nil = nil) : Strin return translation end -def translate_count(locale : String, key : String, count : Int) : String +def translate_count(locale : String, key : String, count : Int, format = NumberFormatting::None) : String # Fallback on english if locale doesn't exist locale = "en-US" if !LOCALES.has_key?(locale) @@ -134,7 +142,14 @@ def translate_count(locale : String, key : String, count : Int) : String end end - return translation.gsub("{{count}}", count.to_s) + case format + when .separator? then count_txt = number_with_separator(count) + when .short? then count_txt = number_to_short_text(count) + when .html_span? then count_txt = "" + count.to_s + "" + else count_txt = count.to_s + end + + return translation.gsub("{{count}}", count_txt) end def translate_bool(locale : String?, translation : Bool) diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index a58571aa..5a93d802 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -10,8 +10,8 @@ <% end %>

    <%= HTML.escape(item.author) %>

    -

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

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    + <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %>
    <%= item.description_html %>
    <% when SearchPlaylist, InvidiousPlaylist %> <% if item.id.starts_with? "RD" %> @@ -24,7 +24,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    "/> -

    <%= number_with_separator(item.video_count) %> videos

    +

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %>

    <%= HTML.escape(item.title) %>

    @@ -94,7 +94,7 @@ <% if item.responds_to?(:views) && item.views %>
    -

    <%= translate(locale, "`x` views", number_to_short_text(item.views || 0)) %>

    +

    <%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %>

    <% end %>
    @@ -160,7 +160,7 @@ <% if item.responds_to?(:views) && item.views %>
    -

    <%= translate(locale, "`x` views", number_to_short_text(item.views || 0)) %>

    +

    <%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %>

    <% end %>
    diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr index 5046abc1..308bd677 100644 --- a/src/invidious/views/edit_playlist.ecr +++ b/src/invidious/views/edit_playlist.ecr @@ -11,7 +11,7 @@

    <%= HTML.escape(playlist.author) %> | - <%= translate(locale, "`x` videos", "#{playlist.video_count}") %> | + <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> | <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | "> " + autocapitalize="none" spellcheck="false" <% if autofocus %>autofocus<% end %> + name="q" placeholder="<%= translate(locale, "search") %>" title="<%= translate(locale, "search") %>" value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>">
    diff --git a/src/invidious/views/search_homepage.ecr b/src/invidious/views/search_homepage.ecr index 45561d1e..2424a1cf 100644 --- a/src/invidious/views/search_homepage.ecr +++ b/src/invidious/views/search_homepage.ecr @@ -14,7 +14,7 @@
    diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index efa434bf..240b523a 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -35,7 +35,7 @@ Invidious
    <% end %> -- cgit v1.2.3 From a2600acfa9327ae7d1c01af2ccd5595f0617f5e0 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 2 Nov 2021 15:43:26 +0100 Subject: Improve crash page messages * Ask to read the FAQ and search for existing issues on Github * Include links to FAQ and directly to a new github issue * Github issue title is automatically based on exception name * Improved HTML * Minor languages changes --- locales/en-US.json | 7 ++++++- src/invidious/helpers/errors.cr | 35 ++++++++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 91af3d72..55bbbdef 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -430,5 +430,10 @@ "user_created_playlists": "`x` created playlists", "user_saved_playlists": "`x` saved playlists", "Video unavailable": "Video unavailable", - "preferences_save_player_pos_label": "Save playback position: " + "preferences_save_player_pos_label": "Save playback position: ", + "crash_page_you_found_a_bug": "It looks like you found a bug in Invidious!", + "crash_page_before_reporting": "Before reporting a bug, make sure that you have:", + "crash_page_read_the_faq": "looked at the Frenquently Asked Queqtions (FAQ)", + "crash_page_search_issue": "searched for existing issues on Github", + "crash_page_report_issue": "If none of the above helped, please open a new issue on GitHub and include the following text in your message:" } diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index d10762c5..dbcc6068 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -26,19 +26,44 @@ def error_template_helper(env : HTTP::Server::Context, locale : String?, status_ if exception.is_a?(InfoException) return error_template_helper(env, locale, status_code, exception.message || "") end + env.response.content_type = "text/html" env.response.status_code = status_code - issue_template = %(Title: `#{exception.message} (#{exception.class})`) + + issue_title = "#{exception.message} (#{exception.class})" + + issue_template = %(Title: `#{issue_title}`) issue_template += %(\nDate: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}`) issue_template += %(\nRoute: `#{env.request.resource}`) issue_template += %(\nVersion: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}`) # issue_template += github_details("Preferences", env.get("preferences").as(Preferences).to_pretty_json) issue_template += github_details("Backtrace", exception.inspect_with_backtrace) + + # URLs for the error message below + url_faq = "https://github.com/iv-org/documentation/blob/master/FAQ.md" + url_search_issues = "https://github.com/iv-org/invidious/issues" + + url_new_issue = "https://github.com/iv-org/invidious/issues/new" + url_new_issue += "?labels=bug&template=bug_report.md&title=" + url_new_issue += URI.encode_www_form("[Bug] " + issue_title) + error_message = <<-END_HTML - Looks like you've found a bug in Invidious. Please open a new issue - on GitHub - and include the following text in your message: -
    #{issue_template}
    +
    +

    #{translate(locale, "crash_page_you_found_a_bug")}

    +

    + +

    #{translate(locale, "crash_page_before_reporting")}

    +
      +
    • #{translate(locale, "crash_page_read_the_faq", url_faq)}
    • +
    • #{translate(locale, "crash_page_search_issue", url_search_issues)}
    • +
    + +
    +

    #{translate(locale, "crash_page_report_issue", url_new_issue)}

    + + +
    #{issue_template}
    +
    END_HTML next_steps = error_redirect_helper(env, locale) -- cgit v1.2.3 From 34a79c5f1e54923caee8fbe8396f7b91228fa46e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 10 Jan 2022 22:01:12 +0100 Subject: Don't show "next steps" message with a stack trace Usually, next steps are after the error message. Here, we want the same options to be right above the stack trace, so users are less likely to report duplicates. --- locales/en-US.json | 2 ++ src/invidious/helpers/errors.cr | 10 ++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 418798cc..9a2d3294 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -433,6 +433,8 @@ "preferences_save_player_pos_label": "Save playback position: ", "crash_page_you_found_a_bug": "It looks like you found a bug in Invidious!", "crash_page_before_reporting": "Before reporting a bug, make sure that you have:", + "crash_page_refresh": "Tried to refresh the page", + "crash_page_switch_instance": "Tried to use another instance", "crash_page_read_the_faq": "looked at the Frenquently Asked Queqtions (FAQ)", "crash_page_search_issue": "searched for existing issues on Github", "crash_page_report_issue": "If none of the above helped, please open a new issue on GitHub (preferably in English) and include the following text in your message (do NOT translate that text):" diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index dbcc6068..d441165d 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -43,6 +43,8 @@ def error_template_helper(env : HTTP::Server::Context, locale : String?, status_ url_faq = "https://github.com/iv-org/documentation/blob/master/FAQ.md" url_search_issues = "https://github.com/iv-org/invidious/issues" + url_switch = "https://redirect.invidious.io" + env.request.resource + url_new_issue = "https://github.com/iv-org/invidious/issues/new" url_new_issue += "?labels=bug&template=bug_report.md&title=" url_new_issue += URI.encode_www_form("[Bug] " + issue_title) @@ -52,8 +54,10 @@ def error_template_helper(env : HTTP::Server::Context, locale : String?, status_

    #{translate(locale, "crash_page_you_found_a_bug")}



    -

    #{translate(locale, "crash_page_before_reporting")}

    +

    #{translate(locale, "crash_page_before_reporting")}

    @@ -66,7 +70,9 @@ def error_template_helper(env : HTTP::Server::Context, locale : String?, status_
    END_HTML - next_steps = error_redirect_helper(env, locale) + # Don't show the usual "next steps" widget. The same options are + # proposed above the error message, just worded differently. + next_steps = "" return templated "error" end -- cgit v1.2.3 From b2a738cf13919625e40bfcd31a0551c465a58941 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Mon, 17 Jan 2022 12:11:47 -0600 Subject: Fix loading reddit comments when there are no threads found --- src/invidious/comments.cr | 19 +++++++++++-------- src/invidious/routes/api/v1/videos.cr | 10 ++++------ 2 files changed, 15 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 256a294e..5302cc53 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -268,18 +268,20 @@ def fetch_reddit_comments(id, sort_by = "confidence") headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} # TODO: Use something like #479 for a static list of instances to use here - query = "(url:3D#{id}%20OR%20url:#{id})%20(site:invidio.us%20OR%20site:youtube.com%20OR%20site:youtu.be)" - search_results = client.get("/search.json?q=#{query}", headers) + query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"}) + search_results = client.get("/search.json?#{query}", headers) if search_results.status_code == 200 search_results = RedditThing.from_json(search_results.body) # For videos that have more than one thread, choose the one with the highest score - thread = search_results.data.as(RedditListing).children.sort_by { |child| child.data.as(RedditLink).score }[-1] - thread = thread.data.as(RedditLink) - - result = client.get("/r/#{thread.subreddit}/comments/#{thread.id}.json?limit=100&sort=#{sort_by}", headers).body - result = Array(RedditThing).from_json(result) + threads = search_results.data.as(RedditListing).children + thread = threads.max_by? { |child| child.data.as(RedditLink).score }.try(&.data.as(RedditLink)) + result = thread.try do |t| + body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body + Array(RedditThing).from_json(body) + end + result ||= [] of RedditThing elsif search_results.status_code == 302 # Previously, if there was only one result then the API would redirect to that result. # Now, it appears it will still return a listing so this section is likely unnecessary. @@ -294,7 +296,8 @@ def fetch_reddit_comments(id, sort_by = "confidence") client.close - comments = result[1].data.as(RedditListing).children + comments = result[1]?.try(&.data.as(RedditListing).children) + comments ||= [] of RedditThing return comments, thread end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 4d244e7f..3a013ba0 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -330,18 +330,13 @@ module Invidious::Routes::API::V1::Videos begin comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) - content_html = template_reddit_comments(comments, locale) - - content_html = fill_links(content_html, "https", "www.reddit.com") - content_html = replace_links(content_html) rescue ex comments = nil reddit_thread = nil - content_html = "" end if !reddit_thread || !comments - haltf env, 404 + return error_json(404, "No reddit threads found") end if format == "json" @@ -350,6 +345,9 @@ module Invidious::Routes::API::V1::Videos return reddit_thread.to_json else + content_html = template_reddit_comments(comments, locale) + content_html = fill_links(content_html, "https", "www.reddit.com") + content_html = replace_links(content_html) response = { "title" => reddit_thread.title, "permalink" => reddit_thread.permalink, -- cgit v1.2.3 From 9233f71549ce397dc82cba90d0379bc461f64e4b Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Mon, 17 Jan 2022 13:03:36 -0600 Subject: Use &.methods where possible instead of curly braces --- src/invidious/comments.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 5302cc53..dda92440 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -276,7 +276,7 @@ def fetch_reddit_comments(id, sort_by = "confidence") # For videos that have more than one thread, choose the one with the highest score threads = search_results.data.as(RedditListing).children - thread = threads.max_by? { |child| child.data.as(RedditLink).score }.try(&.data.as(RedditLink)) + thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink)) result = thread.try do |t| body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body Array(RedditThing).from_json(body) -- cgit v1.2.3 From 8c2495a3999a2102d0a299cd491356ac8c005d8f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 17 Jan 2022 21:47:29 +0100 Subject: Apply suggestions from review --- locales/en-US.json | 6 +++--- src/invidious/helpers/errors.cr | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 67156f14..f733f7db 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -433,9 +433,9 @@ "preferences_save_player_pos_label": "Save playback position: ", "crash_page_you_found_a_bug": "It looks like you found a bug in Invidious!", "crash_page_before_reporting": "Before reporting a bug, make sure that you have:", - "crash_page_refresh": "Tried to refresh the page", - "crash_page_switch_instance": "Tried to use another instance", - "crash_page_read_the_faq": "looked at the Frenquently Asked Questions (FAQ)", + "crash_page_refresh": "tried to refresh the page", + "crash_page_switch_instance": "tried to use another instance", + "crash_page_read_the_faq": "read the Frenquently Asked Questions (FAQ)", "crash_page_search_issue": "searched for existing issues on Github", "crash_page_report_issue": "If none of the above helped, please open a new issue on GitHub (preferably in English) and include the following text in your message (do NOT translate that text):" } diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index d441165d..26c38669 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -56,8 +56,8 @@ def error_template_helper(env : HTTP::Server::Context, locale : String?, status_

    #{translate(locale, "crash_page_before_reporting")}

    -- cgit v1.2.3 From 212f6d6bf5861ed54024992e5babd50dc5ac62a6 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Mon, 17 Jan 2022 09:36:42 -0600 Subject: Fix channel search json parse to not raise --- src/invidious/search.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 2095721c..0bb7c69d 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -5,7 +5,7 @@ def channel_search(query, page, channel) response = YT_POOL.client &.get("/user/#{channel}") response = YT_POOL.client &.get("/c/#{channel}") if response.status_code == 404 initial_data = extract_initial_data(response.body) - ucid = initial_data["header"]["c4TabbedHeaderRenderer"]?.try &.["channelId"].as_s? + ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?) raise InfoException.new("Impossible to extract channel ID from page") if !ucid else ucid = channel -- cgit v1.2.3 From 97dceb3a5a8037fffc28b0e2deca4ebc42b24177 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Mon, 17 Jan 2022 09:49:29 -0600 Subject: Custom error on channel search, handle in search --- src/invidious/search.cr | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 0bb7c69d..6cb61e7d 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -1,3 +1,6 @@ +class ChannelSearchException < InfoException +end + def channel_search(query, page, channel) response = YT_POOL.client &.get("/channel/#{channel}") @@ -6,7 +9,7 @@ def channel_search(query, page, channel) response = YT_POOL.client &.get("/c/#{channel}") if response.status_code == 404 initial_data = extract_initial_data(response.body) ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?) - raise InfoException.new("Impossible to extract channel ID from page") if !ucid + raise ChannelSearchException.new("Impossible to extract channel ID from page") if !ucid else ucid = channel end @@ -210,7 +213,13 @@ def process_search_query(query, page, user, region) search_query = (query.split(" ") - operators).join(" ") if channel - count, items = channel_search(search_query, page, channel) + begin + count, items = channel_search(search_query, page, channel) + rescue ChannelSearchException + # most likely reason for this is that they provided an invalid channel id to the search + count = 0 + items = [] of ChannelVideo + end elsif subscriptions if view_name items = PG_DB.query_all("SELECT id,title,published,updated,ucid,author,length_seconds FROM ( -- cgit v1.2.3 From d4f3139b734c401714682559b7b0137a5db9b3bd Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Mon, 17 Jan 2022 09:59:42 -0600 Subject: Don't catch and provide better error message instead --- src/invidious/search.cr | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 6cb61e7d..5b824307 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -1,4 +1,7 @@ class ChannelSearchException < InfoException + def initialize(channel : String) + super "Unable to find channel with id of '#{channel}'. Are you sure that's an actual channel id?" + end end def channel_search(query, page, channel) @@ -9,7 +12,7 @@ def channel_search(query, page, channel) response = YT_POOL.client &.get("/c/#{channel}") if response.status_code == 404 initial_data = extract_initial_data(response.body) ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?) - raise ChannelSearchException.new("Impossible to extract channel ID from page") if !ucid + raise ChannelSearchException.new(channel) if !ucid else ucid = channel end @@ -213,13 +216,7 @@ def process_search_query(query, page, user, region) search_query = (query.split(" ") - operators).join(" ") if channel - begin - count, items = channel_search(search_query, page, channel) - rescue ChannelSearchException - # most likely reason for this is that they provided an invalid channel id to the search - count = 0 - items = [] of ChannelVideo - end + 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 ( -- cgit v1.2.3 From 56e505164d5faa1b3db15a18e0a0359d4b66d468 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Tue, 18 Jan 2022 18:56:26 -0600 Subject: 404 error with message and provide example --- src/invidious/routes/search.cr | 2 ++ src/invidious/search.cr | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index c256d156..5f9bf5e0 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -55,6 +55,8 @@ module Invidious::Routes::Search begin search_query, count, videos, operators = process_search_query(query, page, user, region: region) + rescue ex : ChannelSearchException + return error_template(404, "Unable to find channel with id of '#{ex.channel}'. Are you sure that's an actual channel id? It will look like 'UC4QobU6STFB0P71PMvOGN5A'.") rescue ex return error_template(500, ex) end diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 5b824307..0f6dc6eb 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -1,6 +1,7 @@ class ChannelSearchException < InfoException - def initialize(channel : String) - super "Unable to find channel with id of '#{channel}'. Are you sure that's an actual channel id?" + getter channel : String + + def initialize(@channel) end end -- cgit v1.2.3 From 574e35a720adea4132ae91ce1c70ca0c34461d6c Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Wed, 19 Jan 2022 09:01:13 -0600 Subject: HTML escape user input --- src/invidious/routes/search.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 5f9bf5e0..19f33a40 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -56,7 +56,7 @@ module Invidious::Routes::Search begin search_query, count, videos, operators = process_search_query(query, page, user, region: region) rescue ex : ChannelSearchException - return error_template(404, "Unable to find channel with id of '#{ex.channel}'. Are you sure that's an actual channel id? It will look like 'UC4QobU6STFB0P71PMvOGN5A'.") + return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It will look like 'UC4QobU6STFB0P71PMvOGN5A'.") rescue ex return error_template(500, ex) end -- cgit v1.2.3 From c5967ad572191ad7b99dec08111974b04dffc6d0 Mon Sep 17 00:00:00 2001 From: Matthew McGarvey Date: Tue, 25 Jan 2022 11:35:19 -0600 Subject: will -> should Co-authored-by: Samantaz Fox --- src/invidious/routes/search.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 19f33a40..5e606adf 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -56,7 +56,7 @@ module Invidious::Routes::Search begin search_query, count, videos, operators = process_search_query(query, page, user, region: region) rescue ex : ChannelSearchException - return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It will look like 'UC4QobU6STFB0P71PMvOGN5A'.") + return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.") rescue ex return error_template(500, ex) end -- cgit v1.2.3 From 5ad2fc64b4d34cb2cb5e0fcf0fd3777b35058a75 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 6 Jan 2022 22:01:09 +0100 Subject: DB: Move a forgotten 'UPDATE channels' statement --- src/invidious/database/channels.cr | 10 ++++++++++ src/invidious/routes/feeds.cr | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/database/channels.cr b/src/invidious/database/channels.cr index 134cf59d..b4552733 100644 --- a/src/invidious/database/channels.cr +++ b/src/invidious/database/channels.cr @@ -42,6 +42,16 @@ module Invidious::Database::Channels PG_DB.exec(request, Time.utc, author, id) end + def update_subscription_time(id : String) + request = <<-SQL + UPDATE channels + SET subscribed = $1 + WHERE id = $2 + SQL + + PG_DB.exec(request, Time.utc, id) + end + def update_mark_deleted(id : String) request = <<-SQL UPDATE channels diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index fd8c25ce..c323cdf7 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -362,7 +362,7 @@ module Invidious::Routes::Feeds end if ucid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["channel_id"]? - PG_DB.exec("UPDATE channels SET subscribed = $1 WHERE id = $2", Time.utc, ucid) + Invidious::Database::Channels.update_subscription_time(ucid) elsif plid = HTTP::Params.parse(URI.parse(topic).query.not_nil!)["playlist_id"]? Invidious::Database::Playlists.update_subscription_time(plid) else -- cgit v1.2.3 From a6c9b263da170eb8180f3b609ce7b4cc62ef4b0e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 6 Jan 2022 23:33:52 +0100 Subject: DB: don't pass PG_DB to check_table/check_enum --- src/invidious.cr | 20 ++++++++++---------- src/invidious/database/base.cr | 38 +++++++++++++++++++------------------- 2 files changed, 29 insertions(+), 29 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 7a324bd1..96e5d348 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -113,19 +113,19 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) # Check table integrity if CONFIG.check_tables - Invidious::Database.check_enum(PG_DB, "privacy", PlaylistPrivacy) + Invidious::Database.check_enum("privacy", PlaylistPrivacy) - Invidious::Database.check_table(PG_DB, "channels", InvidiousChannel) - Invidious::Database.check_table(PG_DB, "channel_videos", ChannelVideo) - Invidious::Database.check_table(PG_DB, "playlists", InvidiousPlaylist) - Invidious::Database.check_table(PG_DB, "playlist_videos", PlaylistVideo) - Invidious::Database.check_table(PG_DB, "nonces", Nonce) - Invidious::Database.check_table(PG_DB, "session_ids", SessionId) - Invidious::Database.check_table(PG_DB, "users", User) - Invidious::Database.check_table(PG_DB, "videos", Video) + Invidious::Database.check_table("channels", InvidiousChannel) + Invidious::Database.check_table("channel_videos", ChannelVideo) + Invidious::Database.check_table("playlists", InvidiousPlaylist) + Invidious::Database.check_table("playlist_videos", PlaylistVideo) + Invidious::Database.check_table("nonces", Nonce) + Invidious::Database.check_table("session_ids", SessionId) + Invidious::Database.check_table("users", User) + Invidious::Database.check_table("videos", Video) if CONFIG.cache_annotations - Invidious::Database.check_table(PG_DB, "annotations", Annotation) + Invidious::Database.check_table("annotations", Annotation) end end diff --git a/src/invidious/database/base.cr b/src/invidious/database/base.cr index 6e49ea1a..a6b38f1c 100644 --- a/src/invidious/database/base.cr +++ b/src/invidious/database/base.cr @@ -3,26 +3,26 @@ require "pg" module Invidious::Database extend self - def check_enum(db, enum_name, struct_type = nil) + def check_enum(enum_name, struct_type = nil) return # TODO - if !db.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) + if !PG_DB.query_one?("SELECT true FROM pg_type WHERE typname = $1", enum_name, as: Bool) LOGGER.info("check_enum: CREATE TYPE #{enum_name}") - db.using_connection do |conn| + PG_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, table_name, struct_type = nil) + def check_table(table_name, struct_type = nil) # Create table if it doesn't exist begin - db.exec("SELECT * FROM #{table_name} LIMIT 0") + PG_DB.exec("SELECT * FROM #{table_name} LIMIT 0") rescue ex LOGGER.info("check_table: check_table: CREATE TABLE #{table_name}") - db.using_connection do |conn| + PG_DB.using_connection do |conn| conn.as(PG::Connection).exec_all(File.read("config/sql/#{table_name}.sql")) end end @@ -30,7 +30,7 @@ module Invidious::Database return if !struct_type struct_array = struct_type.type_array - column_array = get_column_array(db, table_name) + column_array = get_column_array(PG_DB, table_name) column_types = File.read("config/sql/#{table_name}.sql").match(/CREATE TABLE public\.#{table_name}\n\((?[\d\D]*?)\);/) .try &.["types"].split(",").map(&.strip).reject &.starts_with?("CONSTRAINT") @@ -41,14 +41,14 @@ module Invidious::Database if !column_array[i]? new_column = column_types.select(&.starts_with?(name))[0] LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + PG_DB.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") next end # Column doesn't exist if !column_array.includes? name new_column = column_types.select(&.starts_with?(name))[0] - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + PG_DB.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") end # Column exists but in the wrong position, rotate @@ -59,29 +59,29 @@ module Invidious::Database # There's a column we didn't expect if !new_column LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]}") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + PG_DB.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - column_array = get_column_array(db, table_name) + column_array = get_column_array(PG_DB, table_name) next end LOGGER.info("check_table: ALTER TABLE #{table_name} ADD COLUMN #{new_column}") - db.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") + PG_DB.exec("ALTER TABLE #{table_name} ADD COLUMN #{new_column}") LOGGER.info("check_table: UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") - db.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") + PG_DB.exec("UPDATE #{table_name} SET #{column_array[i]}_new=#{column_array[i]}") LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + PG_DB.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") LOGGER.info("check_table: ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") - db.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") + PG_DB.exec("ALTER TABLE #{table_name} RENAME COLUMN #{column_array[i]}_new TO #{column_array[i]}") - column_array = get_column_array(db, table_name) + column_array = get_column_array(PG_DB, table_name) end else LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") + PG_DB.exec("ALTER TABLE #{table_name} DROP COLUMN #{column_array[i]} CASCADE") end end end @@ -91,14 +91,14 @@ module Invidious::Database column_array.each do |column| if !struct_array.includes? column LOGGER.info("check_table: ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") - db.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") + PG_DB.exec("ALTER TABLE #{table_name} DROP COLUMN #{column} CASCADE") end end end def get_column_array(db, table_name) column_array = [] of String - db.query("SELECT * FROM #{table_name} LIMIT 0") do |rs| + PG_DB.query("SELECT * FROM #{table_name} LIMIT 0") do |rs| rs.column_count.times do |i| column = rs.as(PG::ResultSet).field(i) column_array << column.name -- cgit v1.2.3 From c78f84d5c6677551ca32d57187887bfb37d77750 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 6 Jan 2022 23:47:30 +0100 Subject: DB: Move integrity check to the base.cr file --- src/invidious.cr | 17 +---------------- src/invidious/database/base.cr | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 96e5d348..d3ad18bd 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -112,22 +112,7 @@ OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mo LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) # Check table integrity -if CONFIG.check_tables - Invidious::Database.check_enum("privacy", PlaylistPrivacy) - - Invidious::Database.check_table("channels", InvidiousChannel) - Invidious::Database.check_table("channel_videos", ChannelVideo) - Invidious::Database.check_table("playlists", InvidiousPlaylist) - Invidious::Database.check_table("playlist_videos", PlaylistVideo) - Invidious::Database.check_table("nonces", Nonce) - Invidious::Database.check_table("session_ids", SessionId) - Invidious::Database.check_table("users", User) - Invidious::Database.check_table("videos", Video) - - if CONFIG.cache_annotations - Invidious::Database.check_table("annotations", Annotation) - end -end +Invidious::Database.check_integrity(CONFIG) # Start jobs diff --git a/src/invidious/database/base.cr b/src/invidious/database/base.cr index a6b38f1c..0fb1b6af 100644 --- a/src/invidious/database/base.cr +++ b/src/invidious/database/base.cr @@ -3,6 +3,32 @@ require "pg" module Invidious::Database extend self + # Checks table integrity + # + # Note: config is passed as a parameter to avoid complex + # dependencies between different parts of the software. + def check_integrity(cfg) + return if !cfg.check_tables + Invidious::Database.check_enum("privacy", PlaylistPrivacy) + + Invidious::Database.check_table("channels", InvidiousChannel) + Invidious::Database.check_table("channel_videos", ChannelVideo) + Invidious::Database.check_table("playlists", InvidiousPlaylist) + Invidious::Database.check_table("playlist_videos", PlaylistVideo) + Invidious::Database.check_table("nonces", Nonce) + Invidious::Database.check_table("session_ids", SessionId) + Invidious::Database.check_table("users", User) + Invidious::Database.check_table("videos", Video) + + if cfg.cache_annotations + Invidious::Database.check_table("annotations", Annotation) + end + end + + # + # Table/enum integrity checks + # + def check_enum(enum_name, struct_type = nil) return # TODO -- cgit v1.2.3 From 714a0013320f6126f6ac918e65264919582181b4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 7 Jan 2022 18:05:59 +0100 Subject: DB: playlists: make that 'insert' never raises --- src/invidious/database/playlists.cr | 8 ++------ src/invidious/routes/playlists.cr | 16 ++++------------ 2 files changed, 6 insertions(+), 18 deletions(-) (limited to 'src') diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index 7a5f61dc..a37310d6 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -94,17 +94,13 @@ module Invidious::Database::Playlists # Salect # ------------------- - def select(*, id : String, raise_on_fail : Bool = false) : InvidiousPlaylist? + def select(*, id : String) : InvidiousPlaylist? request = <<-SQL SELECT * FROM playlists WHERE id = $1 SQL - if raise_on_fail - return PG_DB.query_one(request, id, as: InvidiousPlaylist) - else - return PG_DB.query_one?(request, id, as: InvidiousPlaylist) - end + return PG_DB.query_one?(request, id, as: InvidiousPlaylist) end def select_all(*, author : String) : Array(InvidiousPlaylist) diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index d437b79c..1c4f1bef 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -151,12 +151,8 @@ module Invidious::Routes::Playlists page = env.params.query["page"]?.try &.to_i? page ||= 1 - begin - playlist = Invidious::Database::Playlists.select(id: plid, raise_on_fail: true) - if !playlist || playlist.author != user.email - return env.redirect referer - end - rescue ex + playlist = Invidious::Database::Playlists.select(id: plid) + if !playlist || playlist.author != user.email return env.redirect referer end @@ -235,12 +231,8 @@ module Invidious::Routes::Playlists page = env.params.query["page"]?.try &.to_i? page ||= 1 - begin - playlist = Invidious::Database::Playlists.select(id: plid, raise_on_fail: true) - if !playlist || playlist.author != user.email - return env.redirect referer - end - rescue ex + playlist = Invidious::Database::Playlists.select(id: plid) + if !playlist || playlist.author != user.email return env.redirect referer end -- cgit v1.2.3 From ce4a52325b6ed77a9829d46621808ec147e7e7c2 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 26 Jan 2022 01:49:29 +0100 Subject: db: use now() function instead of passing Time.utc --- src/invidious/database/channels.cr | 18 +++++++++--------- src/invidious/database/playlists.cr | 18 +++++++++--------- src/invidious/database/sessions.cr | 4 ++-- src/invidious/database/users.cr | 6 +++--- 4 files changed, 23 insertions(+), 23 deletions(-) (limited to 'src') diff --git a/src/invidious/database/channels.cr b/src/invidious/database/channels.cr index b4552733..e35b981d 100644 --- a/src/invidious/database/channels.cr +++ b/src/invidious/database/channels.cr @@ -35,31 +35,31 @@ module Invidious::Database::Channels def update_author(id : String, author : String) request = <<-SQL UPDATE channels - SET updated = $1, author = $2, deleted = false - WHERE id = $3 + SET updated = now(), author = $1, deleted = false + WHERE id = $2 SQL - PG_DB.exec(request, Time.utc, author, id) + PG_DB.exec(request, author, id) end def update_subscription_time(id : String) request = <<-SQL UPDATE channels - SET subscribed = $1 - WHERE id = $2 + SET subscribed = now() + WHERE id = $1 SQL - PG_DB.exec(request, Time.utc, id) + PG_DB.exec(request, id) end def update_mark_deleted(id : String) request = <<-SQL UPDATE channels - SET updated = $1, deleted = true - WHERE id = $2 + SET updated = now(), deleted = true + WHERE id = $1 SQL - PG_DB.exec(request, Time.utc, id) + PG_DB.exec(request, id) end # ------------------- diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index a37310d6..c6754a1e 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -59,11 +59,11 @@ module Invidious::Database::Playlists def update_subscription_time(id : String) request = <<-SQL UPDATE playlists - SET subscribed = $1 - WHERE id = $2 + SET subscribed = now() + WHERE id = $1 SQL - PG_DB.exec(request, Time.utc, id) + PG_DB.exec(request, id) end def update_video_added(id : String, index : String | Int64) @@ -71,11 +71,11 @@ module Invidious::Database::Playlists UPDATE playlists SET index = array_append(index, $1), video_count = cardinality(index) + 1, - updated = $2 - WHERE id = $3 + updated = now() + WHERE id = $2 SQL - PG_DB.exec(request, index, Time.utc, id) + PG_DB.exec(request, index, id) end def update_video_removed(id : String, index : String | Int64) @@ -83,11 +83,11 @@ module Invidious::Database::Playlists UPDATE playlists SET index = array_remove(index, $1), video_count = cardinality(index) - 1, - updated = $2 - WHERE id = $3 + updated = now() + WHERE id = $2 SQL - PG_DB.exec(request, index, Time.utc, id) + PG_DB.exec(request, index, id) end # ------------------- diff --git a/src/invidious/database/sessions.cr b/src/invidious/database/sessions.cr index d5f85dd6..96587082 100644 --- a/src/invidious/database/sessions.cr +++ b/src/invidious/database/sessions.cr @@ -10,12 +10,12 @@ module Invidious::Database::SessionIDs def insert(sid : String, email : String, handle_conflicts : Bool = false) request = <<-SQL INSERT INTO session_ids - VALUES ($1, $2, $3) + VALUES ($1, $2, now()) SQL request += " ON CONFLICT (id) DO NOTHING" if handle_conflicts - PG_DB.exec(request, sid, email, Time.utc) + PG_DB.exec(request, sid, email) end # ------------------- diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr index 53724dbf..26be4270 100644 --- a/src/invidious/database/users.cr +++ b/src/invidious/database/users.cr @@ -143,11 +143,11 @@ module Invidious::Database::Users def clear_notifications(user : User) request = <<-SQL UPDATE users - SET notifications = '{}', updated = $1 - WHERE email = $2 + SET notifications = '{}', updated = now() + WHERE email = $1 SQL - PG_DB.exec(request, Time.utc, user.email) + PG_DB.exec(request, user.email) end # ------------------- -- cgit v1.2.3 From d755d05f88232be3e8290eb7d6a1ac363b93b735 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Sat, 27 Nov 2021 01:16:09 -0600 Subject: Move more routes to new pattern --- src/invidious.cr | 507 +--------------------------------- src/invidious/routes/notifications.cr | 78 ++++++ src/invidious/routes/preferences.cr | 187 +++++++++++++ src/invidious/routes/subscriptions.cr | 168 +++++++++++ src/invidious/routes/watch.cr | 66 +++++ 5 files changed, 507 insertions(+), 499 deletions(-) create mode 100644 src/invidious/routes/notifications.cr create mode 100644 src/invidious/routes/subscriptions.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 7a324bd1..0a3bad8b 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -339,6 +339,7 @@ end end Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle + Invidious::Routing.post "/watch_ajax", Invidious::Routes::Watch, :mark_watched Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect @@ -372,6 +373,8 @@ end Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme + Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control + Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control # Feeds Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect @@ -399,6 +402,11 @@ Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails +Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify + +Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription +Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager + # API routes (macro) define_v1_api_routes() @@ -406,505 +414,6 @@ define_v1_api_routes() define_api_manifest_routes() define_video_playback_routes() -# Users - -post "/watch_ajax" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env, "/feed/subscriptions") - - redirect = env.params.query["redirect"]? - redirect ||= "true" - redirect = redirect == "true" - - if !user - if redirect - next env.redirect referer - else - next error_json(403, "No such user") - end - end - - user = user.as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - id = env.params.query["id"]? - if !id - env.response.status_code = 400 - next - end - - begin - validate_request(token, sid, env.request, HMAC_KEY, locale) - rescue ex - if redirect - next error_template(400, ex) - else - next error_json(400, ex) - end - end - - if env.params.query["action_mark_watched"]? - action = "action_mark_watched" - elsif env.params.query["action_mark_unwatched"]? - action = "action_mark_unwatched" - else - next env.redirect referer - end - - case action - when "action_mark_watched" - if !user.watched.includes? id - Invidious::Database::Users.mark_watched(user, id) - end - when "action_mark_unwatched" - Invidious::Database::Users.mark_unwatched(user, id) - else - next error_json(400, "Unsupported action #{action}") - end - - if redirect - env.redirect referer - else - env.response.content_type = "application/json" - "{}" - end -end - -# /modify_notifications -# will "ding" all subscriptions. -# /modify_notifications?receive_all_updates=false&receive_no_updates=false -# will "unding" all subscriptions. -get "/modify_notifications" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env, "/") - - redirect = env.params.query["redirect"]? - redirect ||= "false" - redirect = redirect == "true" - - if !user - if redirect - next env.redirect referer - else - next error_json(403, "No such user") - end - end - - user = user.as(User) - - if !user.password - channel_req = {} of String => String - - channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true" - channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || "" - channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true" - - channel_req.reject! { |k, v| v != "true" && v != "false" } - - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) - - cookies = HTTP::Cookies.from_client_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': "(?[^"]+)"/) - session_token = match["session_token"] - else - next env.redirect referer - end - - headers["content-type"] = "application/x-www-form-urlencoded" - channel_req["session_token"] = session_token - - subs = XML.parse_html(html.body) - subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel| - channel_id = channel.content.lstrip("/channel/").not_nil! - channel_req["channel_id"] = channel_id - - YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req) - end - end - - if redirect - env.redirect referer - else - env.response.content_type = "application/json" - "{}" - end -end - -post "/subscription_ajax" do |env| - locale = 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 - next error_json(403, "No such user") - 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, locale) - rescue ex - if redirect - next error_template(400, ex) - else - next error_json(400, ex) - end - end - - if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1 - action = "action_create_subscription_to_channel" - elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1 - action = "action_remove_subscriptions" - else - next env.redirect referer - end - - channel_id = env.params.query["c"]? - channel_id ||= "" - - if !user.password - # Sync subscriptions with YouTube - subscribe_ajax(channel_id, action, env.request.headers) - end - - case action - when "action_create_subscription_to_channel" - if !user.subscriptions.includes? channel_id - get_channel(channel_id, false, false) - Invidious::Database::Users.subscribe_channel(user, channel_id) - end - when "action_remove_subscriptions" - Invidious::Database::Users.unsubscribe_channel(user, channel_id) - else - next error_json(400, "Unsupported action #{action}") - end - - if redirect - env.redirect referer - else - env.response.content_type = "application/json" - "{}" - end -end - -get "/subscription_manager" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - - if !user.password - # Refresh account - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - user, sid = get_user(sid, headers) - end - - action_takeout = env.params.query["action_takeout"]?.try &.to_i? - action_takeout ||= 0 - action_takeout = action_takeout == 1 - - format = env.params.query["format"]? - format ||= "rss" - - subscriptions = Invidious::Database::Channels.select(user.subscriptions) - subscriptions.sort_by!(&.author.downcase) - - if action_takeout - if format == "json" - env.response.content_type = "application/json" - env.response.headers["content-disposition"] = "attachment" - playlists = Invidious::Database::Playlists.select_like_iv(user.email) - - next JSON.build do |json| - json.object do - json.field "subscriptions", user.subscriptions - json.field "watch_history", user.watched - json.field "preferences", user.preferences - json.field "playlists" do - json.array do - playlists.each do |playlist| - json.object do - json.field "title", playlist.title - json.field "description", html_to_content(playlist.description_html) - json.field "privacy", playlist.privacy.to_s - json.field "videos" do - json.array do - Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| - json.string video_id - end - end - end - end - end - end - end - end - end - else - env.response.content_type = "application/xml" - env.response.headers["content-disposition"] = "attachment" - export = XML.build do |xml| - xml.element("opml", version: "1.1") do - xml.element("body") do - if format == "newpipe" - title = "YouTube Subscriptions" - else - title = "Invidious Subscriptions" - end - - xml.element("outline", text: title, title: title) do - subscriptions.each do |channel| - if format == "newpipe" - xml_url = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}" - else - xml_url = "#{HOST_URL}/feed/channel/#{channel.id}" - end - - xml.element("outline", text: channel.author, title: channel.author, - "type": "rss", xmlUrl: xml_url) - end - end - end - end - end - - next export.gsub(%(\n), "") - end - end - - templated "subscription_manager" -end - -get "/data_control" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - - templated "data_control" -end - -post "/data_control" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - referer = get_referer(env) - - if user - user = user.as(User) - - # TODO: Find a way to prevent browser timeout - - HTTP::FormData.parse(env.request) do |part| - body = part.body.gets_to_end - type = part.headers["Content-Type"] - - next if body.empty? - - # TODO: Unify into single import based on content-type - case part.name - when "import_invidious" - body = JSON.parse(body) - - if body["subscriptions"]? - user.subscriptions += body["subscriptions"].as_a.map(&.as_s) - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions, false, false) - - Invidious::Database::Users.update_subscriptions(user) - end - - if body["watch_history"]? - user.watched += body["watch_history"].as_a.map(&.as_s) - user.watched.uniq! - Invidious::Database::Users.update_watch_history(user) - end - - if body["preferences"]? - user.preferences = Preferences.from_json(body["preferences"].to_json) - Invidious::Database::Users.update_preferences(user) - end - - if playlists = body["playlists"]?.try &.as_a? - playlists.each do |item| - title = item["title"]?.try &.as_s?.try &.delete("<>") - description = item["description"]?.try &.as_s?.try &.delete("\r") - privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy } - - next if !title - next if !description - next if !privacy - - playlist = create_playlist(title, privacy, user) - Invidious::Database::Playlists.update_description(playlist.id, description) - - videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| - raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 - - video_id = video_id.try &.as_s? - next if !video_id - - begin - video = get_video(video_id) - rescue ex - next - 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), - }) - - Invidious::Database::PlaylistVideos.insert(playlist_video) - Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) - end - end - end - when "import_youtube" - filename = part.filename || "" - extension = filename.split(".").last - - if extension == "xml" || type == "application/xml" || type == "text/xml" - subscriptions = XML.parse(body) - user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| - channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] - end - elsif extension == "json" || type == "application/json" - subscriptions = JSON.parse(body) - user.subscriptions += subscriptions.as_a.compact_map do |entry| - entry["snippet"]["resourceId"]["channelId"].as_s - end - elsif extension == "csv" || type == "text/csv" - subscriptions = parse_subscription_export_csv(body) - user.subscriptions += subscriptions - else - halt(env, status_code: 415, - response: error_template(415, "Invalid subscription file uploaded") - ) - end - - user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, false, false) - - Invidious::Database::Users.update_subscriptions(user) - when "import_freetube" - user.subscriptions += body.scan(/"channelId":"(?[a-zA-Z0-9_-]{24})"/).map do |md| - md["channel_id"] - end - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions, false, false) - - Invidious::Database::Users.update_subscriptions(user) - when "import_newpipe_subscriptions" - body = JSON.parse(body) - user.subscriptions += body["subscriptions"].as_a.compact_map do |channel| - if match = channel["url"].as_s.match(/\/channel\/(?UC[a-zA-Z0-9_-]{22})/) - next match["channel"] - elsif match = channel["url"].as_s.match(/\/user\/(?.+)/) - response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US") - html = XML.parse_html(response.body) - ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] - next ucid if ucid - end - - nil - end - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions, false, false) - - Invidious::Database::Users.update_subscriptions(user) - when "import_newpipe" - Compress::Zip::Reader.open(IO::Memory.new(body)) do |file| - file.each_entry do |entry| - if entry.filename == "newpipe.db" - tempfile = File.tempfile(".db") - File.write(tempfile.path, entry.io.gets_to_end) - db = DB.open("sqlite3://" + tempfile.path) - - user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v=")) - user.watched.uniq! - - Invidious::Database::Users.update_watch_history(user) - - user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/")) - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions, false, false) - - Invidious::Database::Users.update_subscriptions(user) - - db.close - tempfile.delete - end - end - end - else nil # Ignore - end - end - end - - env.redirect referer -end - get "/change_password" do |env| locale = env.get("preferences").as(Preferences).locale diff --git a/src/invidious/routes/notifications.cr b/src/invidious/routes/notifications.cr new file mode 100644 index 00000000..272a3dc7 --- /dev/null +++ b/src/invidious/routes/notifications.cr @@ -0,0 +1,78 @@ +module Invidious::Routes::Notifications + # /modify_notifications + # will "ding" all subscriptions. + # /modify_notifications?receive_all_updates=false&receive_no_updates=false + # will "unding" all subscriptions. + def self.modify(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, "/") + + redirect = env.params.query["redirect"]? + redirect ||= "false" + redirect = redirect == "true" + + if !user + if redirect + return env.redirect referer + else + return error_json(403, "No such user") + end + end + + user = user.as(User) + + if !user.password + channel_req = {} of String => String + + channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true" + channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || "" + channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true" + + channel_req.reject! { |k, v| v != "true" && v != "false" } + + headers = HTTP::Headers.new + headers["Cookie"] = env.request.headers["Cookie"] + + html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) + + cookies = HTTP::Cookies.from_client_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': "(?[^"]+)"/) + session_token = match["session_token"] + else + return env.redirect referer + end + + headers["content-type"] = "application/x-www-form-urlencoded" + channel_req["session_token"] = session_token + + subs = XML.parse_html(html.body) + subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel| + channel_id = channel.content.lstrip("/channel/").not_nil! + channel_req["channel_id"] = channel_id + + YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req) + end + end + + if redirect + env.redirect referer + else + env.response.content_type = "application/json" + "{}" + end + end +end diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index a832076c..f7bc5a07 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -285,4 +285,191 @@ module Invidious::Routes::PreferencesRoute "{}" end end + + def self.data_control(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + + templated "data_control" + end + + def self.update_data_control(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + referer = get_referer(env) + + if user + user = user.as(User) + + # TODO: Find a way to prevent browser timeout + + HTTP::FormData.parse(env.request) do |part| + body = part.body.gets_to_end + type = part.headers["Content-Type"] + + return if body.empty? + + # TODO: Unify into single import based on content-type + case part.name + when "import_invidious" + body = JSON.parse(body) + + if body["subscriptions"]? + user.subscriptions += body["subscriptions"].as_a.map(&.as_s) + user.subscriptions.uniq! + + user.subscriptions = get_batch_channels(user.subscriptions, false, false) + + Invidious::Database::Users.update_subscriptions(user) + end + + if body["watch_history"]? + user.watched += body["watch_history"].as_a.map(&.as_s) + user.watched.uniq! + Invidious::Database::Users.update_watch_history(user) + end + + if body["preferences"]? + user.preferences = Preferences.from_json(body["preferences"].to_json) + Invidious::Database::Users.update_preferences(user) + end + + if playlists = body["playlists"]?.try &.as_a? + playlists.each do |item| + title = item["title"]?.try &.as_s?.try &.delete("<>") + description = item["description"]?.try &.as_s?.try &.delete("\r") + privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy } + + return if !title + return if !description + return if !privacy + + playlist = create_playlist(title, privacy, user) + Invidious::Database::Playlists.update_description(playlist.id, description) + + videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| + raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 + + video_id = video_id.try &.as_s? + return if !video_id + + begin + video = get_video(video_id) + rescue ex + return + 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), + }) + + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) + end + end + end + when "import_youtube" + filename = part.filename || "" + extension = filename.split(".").last + + if extension == "xml" || type == "application/xml" || type == "text/xml" + subscriptions = XML.parse(body) + user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| + channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] + end + elsif extension == "json" || type == "application/json" + subscriptions = JSON.parse(body) + user.subscriptions += subscriptions.as_a.compact_map do |entry| + entry["snippet"]["resourceId"]["channelId"].as_s + end + elsif extension == "csv" || type == "text/csv" + subscriptions = parse_subscription_export_csv(body) + user.subscriptions += subscriptions + else + haltf(env, status_code: 415, + response: error_template(415, "Invalid subscription file uploaded") + ) + end + + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions, false, false) + + Invidious::Database::Users.update_subscriptions(user) + when "import_freetube" + user.subscriptions += body.scan(/"channelId":"(?[a-zA-Z0-9_-]{24})"/).map do |md| + md["channel_id"] + end + user.subscriptions.uniq! + + user.subscriptions = get_batch_channels(user.subscriptions, false, false) + + Invidious::Database::Users.update_subscriptions(user) + when "import_newpipe_subscriptions" + body = JSON.parse(body) + user.subscriptions += body["subscriptions"].as_a.compact_map do |channel| + if match = channel["url"].as_s.match(/\/channel\/(?UC[a-zA-Z0-9_-]{22})/) + return match["channel"] + elsif match = channel["url"].as_s.match(/\/user\/(?.+)/) + response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US") + html = XML.parse_html(response.body) + ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] + return ucid if ucid + end + + nil + end + user.subscriptions.uniq! + + user.subscriptions = get_batch_channels(user.subscriptions, false, false) + + Invidious::Database::Users.update_subscriptions(user) + when "import_newpipe" + Compress::Zip::Reader.open(IO::Memory.new(body)) do |file| + file.each_entry do |entry| + if entry.filename == "newpipe.db" + tempfile = File.tempfile(".db") + File.write(tempfile.path, entry.io.gets_to_end) + db = DB.open("sqlite3://" + tempfile.path) + + user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v=")) + user.watched.uniq! + + Invidious::Database::Users.update_watch_history(user) + + user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/")) + user.subscriptions.uniq! + + user.subscriptions = get_batch_channels(user.subscriptions, false, false) + + Invidious::Database::Users.update_subscriptions(user) + + db.close + tempfile.delete + end + end + end + else nil # Ignore + end + end + end + + env.redirect referer + end end diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr new file mode 100644 index 00000000..29152afb --- /dev/null +++ b/src/invidious/routes/subscriptions.cr @@ -0,0 +1,168 @@ +module Invidious::Routes::Subscriptions + def self.toggle_subscription(env) + locale = 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 + return env.redirect referer + else + return error_json(403, "No such user") + 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, locale) + rescue ex + if redirect + return error_template(400, ex) + else + return error_json(400, ex) + end + end + + if env.params.query["action_create_subscription_to_channel"]?.try &.to_i?.try &.== 1 + action = "action_create_subscription_to_channel" + elsif env.params.query["action_remove_subscriptions"]?.try &.to_i?.try &.== 1 + action = "action_remove_subscriptions" + else + return env.redirect referer + end + + channel_id = env.params.query["c"]? + channel_id ||= "" + + if !user.password + # Sync subscriptions with YouTube + subscribe_ajax(channel_id, action, env.request.headers) + end + + case action + when "action_create_subscription_to_channel" + if !user.subscriptions.includes? channel_id + get_channel(channel_id, false, false) + Invidious::Database::Users.subscribe_channel(user, channel_id) + end + when "action_remove_subscriptions" + Invidious::Database::Users.unsubscribe_channel(user, channel_id) + else + return error_json(400, "Unsupported action #{action}") + end + + if redirect + env.redirect referer + else + env.response.content_type = "application/json" + "{}" + end + end + + def self.subscription_manager(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + + if !user.password + # Refresh account + headers = HTTP::Headers.new + headers["Cookie"] = env.request.headers["Cookie"] + + user, sid = get_user(sid, headers) + end + + action_takeout = env.params.query["action_takeout"]?.try &.to_i? + action_takeout ||= 0 + action_takeout = action_takeout == 1 + + format = env.params.query["format"]? + format ||= "rss" + + subscriptions = Invidious::Database::Channels.select(user.subscriptions) + subscriptions.sort_by!(&.author.downcase) + + if action_takeout + if format == "json" + env.response.content_type = "application/json" + env.response.headers["content-disposition"] = "attachment" + playlists = Invidious::Database::Playlists.select_like_iv(user.email) + + return JSON.build do |json| + json.object do + json.field "subscriptions", user.subscriptions + json.field "watch_history", user.watched + json.field "preferences", user.preferences + json.field "playlists" do + json.array do + playlists.each do |playlist| + json.object do + json.field "title", playlist.title + json.field "description", html_to_content(playlist.description_html) + json.field "privacy", playlist.privacy.to_s + json.field "videos" do + json.array do + Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| + json.string video_id + end + end + end + end + end + end + end + end + end + else + env.response.content_type = "application/xml" + env.response.headers["content-disposition"] = "attachment" + export = XML.build do |xml| + xml.element("opml", version: "1.1") do + xml.element("body") do + if format == "newpipe" + title = "YouTube Subscriptions" + else + title = "Invidious Subscriptions" + end + + xml.element("outline", text: title, title: title) do + subscriptions.each do |channel| + if format == "newpipe" + xml_url = "https://www.youtube.com/feeds/videos.xml?channel_id=#{channel.id}" + else + xml_url = "#{HOST_URL}/feed/channel/#{channel.id}" + end + + xml.element("outline", text: channel.author, title: channel.author, + "type": "rss", xmlUrl: xml_url) + end + end + end + end + end + + return export.gsub(%(\n), "") + end + end + + templated "subscription_manager" + end +end diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 1198f48f..7d048ce8 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -200,4 +200,70 @@ module Invidious::Routes::Watch return env.redirect url end + + def self.mark_watched(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, "/feed/subscriptions") + + redirect = env.params.query["redirect"]? + redirect ||= "true" + redirect = redirect == "true" + + if !user + if redirect + return env.redirect referer + else + return error_json(403, "No such user") + end + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + id = env.params.query["id"]? + if !id + env.response.status_code = 400 + return + end + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + if redirect + return error_template(400, ex) + else + return error_json(400, ex) + end + end + + if env.params.query["action_mark_watched"]? + action = "action_mark_watched" + elsif env.params.query["action_mark_unwatched"]? + action = "action_mark_unwatched" + else + return env.redirect referer + end + + case action + when "action_mark_watched" + if !user.watched.includes? id + Invidious::Database::Users.mark_watched(user, id) + end + when "action_mark_unwatched" + Invidious::Database::Users.mark_unwatched(user, id) + else + return error_json(400, "Unsupported action #{action}") + end + + if redirect + env.redirect referer + else + env.response.content_type = "application/json" + "{}" + end + end end -- cgit v1.2.3 From 8ef1e81294a3426009e705a61a69ef91e1e8f7c2 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Tue, 18 Jan 2022 18:34:32 -0600 Subject: Make certain routes ignored if api only --- src/invidious.cr | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 0a3bad8b..01cfcae3 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -393,6 +393,11 @@ end # Support push notifications via PubSubHubbub Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post + + Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify + + Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription + Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager {% end %} Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht @@ -402,11 +407,6 @@ Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails -Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify - -Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription -Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager - # API routes (macro) define_v1_api_routes() -- cgit v1.2.3 From df9f897ebeb6db5779f3f489645be759c9ed9760 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Tue, 25 Jan 2022 19:28:16 -0600 Subject: Fix code broken when extracting data control route --- src/invidious/routes/preferences.cr | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index f7bc5a07..faae03bc 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -316,7 +316,7 @@ module Invidious::Routes::PreferencesRoute body = part.body.gets_to_end type = part.headers["Content-Type"] - return if body.empty? + next if body.empty? # TODO: Unify into single import based on content-type case part.name @@ -349,9 +349,9 @@ module Invidious::Routes::PreferencesRoute description = item["description"]?.try &.as_s?.try &.delete("\r") privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy } - return if !title - return if !description - return if !privacy + next if !title + next if !description + next if !privacy playlist = create_playlist(title, privacy, user) Invidious::Database::Playlists.update_description(playlist.id, description) @@ -360,12 +360,12 @@ module Invidious::Routes::PreferencesRoute raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 video_id = video_id.try &.as_s? - return if !video_id + next if !video_id begin video = get_video(video_id) rescue ex - return + next end playlist_video = PlaylistVideo.new({ @@ -425,12 +425,12 @@ module Invidious::Routes::PreferencesRoute body = JSON.parse(body) user.subscriptions += body["subscriptions"].as_a.compact_map do |channel| if match = channel["url"].as_s.match(/\/channel\/(?UC[a-zA-Z0-9_-]{22})/) - return match["channel"] + next match["channel"] elsif match = channel["url"].as_s.match(/\/user\/(?.+)/) response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US") html = XML.parse_html(response.body) ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] - return ucid if ucid + next ucid if ucid end nil -- cgit v1.2.3 From 67dd2b419a28510e6d89991e86e5d0aa97cac273 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 26 Jan 2022 17:30:54 +0100 Subject: db: use prepared statements rather than crafted argument list --- src/invidious/database/channels.cr | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/database/channels.cr b/src/invidious/database/channels.cr index e35b981d..df44e485 100644 --- a/src/invidious/database/channels.cr +++ b/src/invidious/database/channels.cr @@ -77,14 +77,13 @@ module Invidious::Database::Channels def select(ids : Array(String)) : Array(InvidiousChannel)? return [] of InvidiousChannel if ids.empty? - values = ids.map { |id| %(('#{id}')) }.join(",") request = <<-SQL SELECT * FROM channels - WHERE id = ANY(VALUES #{values}) + WHERE id = ANY($1) SQL - return PG_DB.query_all(request, as: InvidiousChannel) + return PG_DB.query_all(request, ids, as: InvidiousChannel) end end @@ -127,11 +126,11 @@ module Invidious::Database::ChannelVideos request = <<-SQL SELECT * FROM channel_videos - WHERE id IN (#{arg_array(ids)}) + WHERE id = ANY($1) ORDER BY published DESC SQL - return PG_DB.query_all(request, args: ids, as: ChannelVideo) + return PG_DB.query_all(request, ids, as: ChannelVideo) end def select_notfications(ucid : String, since : Time) : Array(ChannelVideo) -- cgit v1.2.3 From 5e3c9cf2909608badae15ae9196cc5f2cab37b94 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 19 Jan 2022 18:34:35 +0100 Subject: Remove useless arguments from playlist-related functions --- src/invidious/playlists.cr | 36 +++++++++++----------------- src/invidious/routes/api/v1/authenticated.cr | 2 +- src/invidious/routes/api/v1/misc.cr | 10 ++++---- src/invidious/routes/embed.cr | 8 +++---- src/invidious/routes/feeds.cr | 2 +- src/invidious/routes/playlists.cr | 10 ++++---- 6 files changed, 30 insertions(+), 38 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index a09e6cdb..ecebba91 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -90,7 +90,7 @@ struct Playlist property updated : Time property thumbnail : String? - def to_json(offset, locale, json : JSON::Builder, video_id : String? = nil) + def to_json(offset, json : JSON::Builder, video_id : String? = nil) json.object do json.field "type", "playlist" json.field "title", self.title @@ -125,7 +125,7 @@ struct Playlist json.field "videos" do json.array do - videos = get_playlist_videos(self, offset: offset, locale: locale, video_id: video_id) + videos = get_playlist_videos(self, offset: offset, video_id: video_id) videos.each do |video| video.to_json(json) end @@ -134,13 +134,9 @@ struct Playlist end end - def to_json(offset, locale, json : JSON::Builder? = nil, video_id : String? = nil) - if json - to_json(offset, locale, json, video_id: video_id) - else - JSON.build do |json| - to_json(offset, locale, json, video_id: video_id) - end + def to_json(offset, _json : Nil = nil, video_id : String? = nil) + JSON.build do |json| + to_json(offset, json, video_id: video_id) end end @@ -179,7 +175,7 @@ struct InvidiousPlaylist end end - def to_json(offset, locale, json : JSON::Builder, video_id : String? = nil) + def to_json(offset, json : JSON::Builder, video_id : String? = nil) json.object do json.field "type", "invidiousPlaylist" json.field "title", self.title @@ -205,7 +201,7 @@ struct InvidiousPlaylist offset = self.index.index(index) || 0 end - videos = get_playlist_videos(self, offset: offset, locale: locale, video_id: video_id) + videos = get_playlist_videos(self, offset: offset, video_id: video_id) videos.each_with_index do |video, index| video.to_json(json, offset + index) end @@ -214,13 +210,9 @@ struct InvidiousPlaylist end end - def to_json(offset, locale, json : JSON::Builder? = nil, video_id : String? = nil) - if json - to_json(offset, locale, json, video_id: video_id) - else - JSON.build do |json| - to_json(offset, locale, json, video_id: video_id) - end + def to_json(offset, _json : Nil = nil, video_id : String? = nil) + JSON.build do |json| + to_json(offset, json, video_id: video_id) end end @@ -320,7 +312,7 @@ def produce_playlist_continuation(id, index) return continuation end -def get_playlist(plid, locale, refresh = true, force_refresh = false) +def get_playlist(plid : String) if plid.starts_with? "IV" if playlist = Invidious::Database::Playlists.select(id: plid) return playlist @@ -328,11 +320,11 @@ def get_playlist(plid, locale, refresh = true, force_refresh = false) raise InfoException.new("Playlist does not exist.") end else - return fetch_playlist(plid, locale) + return fetch_playlist(plid) end end -def fetch_playlist(plid, locale) +def fetch_playlist(plid : String) if plid.starts_with? "UC" plid = "UU#{plid.lchop("UC")}" end @@ -402,7 +394,7 @@ def fetch_playlist(plid, locale) }) end -def get_playlist_videos(playlist, offset, locale = nil, video_id = nil) +def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, video_id = nil) # Show empy playlist if requested page is out of range # (e.g, when a new playlist has been created, offset will be negative) if offset >= playlist.video_count || offset < 0 diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index fda655ef..19c0ee3f 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -125,7 +125,7 @@ module Invidious::Routes::API::V1::Authenticated JSON.build do |json| json.array do playlists.each do |playlist| - playlist.to_json(0, locale, json) + playlist.to_json(0, json) end end end diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index ac0576a0..d10f4fcc 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -14,7 +14,7 @@ module Invidious::Routes::API::V1::Misc # APIv1 currently uses the same logic for both # user playlists and Invidious playlists. This means that we can't # reasonably split them yet. This should be addressed in APIv2 - def self.get_playlist(env) + def self.get_playlist(env : HTTP::Server::Context) locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" @@ -34,7 +34,7 @@ module Invidious::Routes::API::V1::Misc end begin - playlist = get_playlist(plid, locale) + playlist = get_playlist(plid) rescue ex : InfoException return error_json(404, ex) rescue ex @@ -49,7 +49,7 @@ module Invidious::Routes::API::V1::Misc # includes into the playlist a maximum of 20 videos, before the offset if offset > 0 lookback = offset < 50 ? offset : 50 - response = playlist.to_json(offset - lookback, locale) + response = playlist.to_json(offset - lookback) json_response = JSON.parse(response) else # Unless the continuation is really the offset 0, it becomes expensive. @@ -58,13 +58,13 @@ module Invidious::Routes::API::V1::Misc # it shouldn't happen often though lookback = 0 - response = playlist.to_json(offset, locale, video_id: video_id) + response = playlist.to_json(offset, video_id: video_id) json_response = JSON.parse(response) if json_response["videos"].as_a[0]["index"] != offset offset = json_response["videos"].as_a[0]["index"].as_i lookback = offset < 50 ? offset : 50 - response = playlist.to_json(offset - lookback, locale) + response = playlist.to_json(offset - lookback) json_response = JSON.parse(response) end end diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index ab722ae2..0e9701f0 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -6,9 +6,9 @@ module Invidious::Routes::Embed if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") begin - playlist = get_playlist(plid, locale: locale) + playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 - videos = get_playlist_videos(playlist, offset: offset, locale: locale) + videos = get_playlist_videos(playlist, offset: offset) rescue ex return error_template(500, ex) end @@ -60,9 +60,9 @@ module Invidious::Routes::Embed if plid begin - playlist = get_playlist(plid, locale: locale) + playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 - videos = get_playlist_videos(playlist, offset: offset, locale: locale) + videos = get_playlist_videos(playlist, offset: offset) rescue ex return error_template(500, ex) end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index fd8c25ce..c9271766 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -265,7 +265,7 @@ module Invidious::Routes::Feeds if plid.starts_with? "IV" if playlist = Invidious::Database::Playlists.select(id: plid) - videos = get_playlist_videos(playlist, offset: 0, locale: locale) + videos = get_playlist_videos(playlist, offset: 0) return XML.build(indent: " ", encoding: "UTF-8") do |xml| xml.element("feed", "xmlns:yt": "http://www.youtube.com/xml/schemas/2015", diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index d437b79c..7a502a05 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -66,7 +66,7 @@ module Invidious::Routes::Playlists user = user.as(User) playlist_id = env.params.query["list"] - playlist = get_playlist(playlist_id, locale) + playlist = get_playlist(playlist_id) subscribe_playlist(user, playlist) env.redirect "/playlist?list=#{playlist.id}" @@ -161,7 +161,7 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(playlist, offset: (page - 1) * 100, locale: locale) + videos = get_playlist_videos(playlist, offset: (page - 1) * 100) rescue ex videos = [] of PlaylistVideo end @@ -314,7 +314,7 @@ module Invidious::Routes::Playlists begin playlist_id = env.params.query["playlist_id"] - playlist = get_playlist(playlist_id, locale).as(InvidiousPlaylist) + playlist = get_playlist(playlist_id).as(InvidiousPlaylist) raise "Invalid user" if playlist.author != user.email rescue ex if redirect @@ -405,7 +405,7 @@ module Invidious::Routes::Playlists end begin - playlist = get_playlist(plid, locale) + playlist = get_playlist(plid) rescue ex return error_template(500, ex) end @@ -422,7 +422,7 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(playlist, offset: (page - 1) * 100, locale: locale) + videos = get_playlist_videos(playlist, offset: (page - 1) * 100) rescue ex return error_template(500, "Error encountered while retrieving playlist videos.
    #{ex.message}") end -- cgit v1.2.3 From c7b74aa8b415819f44a4c32ee13ba26b9fd594c8 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 19 Jan 2022 18:47:54 +0100 Subject: Remove useless 'locale' argument from error template functions --- src/invidious/helpers/errors.cr | 70 ++++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 18 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 26c38669..1a1366fe 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -6,8 +6,12 @@ class InfoException < Exception end +# ------------------- +# Issue template +# ------------------- + macro error_template(*args) - error_template_helper(env, locale, {{*args}}) + error_template_helper(env, {{*args}}) end def github_details(summary : String, content : String) @@ -22,11 +26,13 @@ def github_details(summary : String, content : String) return HTML.escape(details) end -def error_template_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception) +def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception) if exception.is_a?(InfoException) - return error_template_helper(env, locale, status_code, exception.message || "") + return error_template_helper(env, status_code, exception.message || "") end + locale = env.get("preferences").as(Preferences).locale + env.response.content_type = "text/html" env.response.status_code = status_code @@ -77,71 +83,99 @@ def error_template_helper(env : HTTP::Server::Context, locale : String?, status_ return templated "error" end -def error_template_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String) +def error_template_helper(env : HTTP::Server::Context, status_code : Int32, message : String) env.response.content_type = "text/html" env.response.status_code = status_code + + locale = env.get("preferences").as(Preferences).locale + error_message = translate(locale, message) - next_steps = error_redirect_helper(env, locale) + next_steps = error_redirect_helper(env) + return templated "error" end +# ------------------- +# Atom feeds +# ------------------- + macro error_atom(*args) - error_atom_helper(env, locale, {{*args}}) + error_atom_helper(env, {{*args}}) end -def error_atom_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception) +def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception) if exception.is_a?(InfoException) - return error_atom_helper(env, locale, status_code, exception.message || "") + return error_atom_helper(env, status_code, exception.message || "") end + env.response.content_type = "application/atom+xml" env.response.status_code = status_code + return "#{exception.inspect_with_backtrace}" end -def error_atom_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String) +def error_atom_helper(env : HTTP::Server::Context, status_code : Int32, message : String) env.response.content_type = "application/atom+xml" env.response.status_code = status_code + return "#{message}" end +# ------------------- +# JSON +# ------------------- + macro error_json(*args) - error_json_helper(env, locale, {{*args}}) + error_json_helper(env, {{*args}}) end -def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil) +def error_json_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil) if exception.is_a?(InfoException) - return error_json_helper(env, locale, status_code, exception.message || "", additional_fields) + return error_json_helper(env, status_code, exception.message || "", additional_fields) end + env.response.content_type = "application/json" env.response.status_code = status_code + error_message = {"error" => exception.message, "errorBacktrace" => exception.inspect_with_backtrace} + if additional_fields error_message = error_message.merge(additional_fields) end + return error_message.to_json end -def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, exception : Exception) - return error_json_helper(env, locale, status_code, exception, nil) +def error_json_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception) + return error_json_helper(env, status_code, exception, nil) end -def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil) +def error_json_helper(env : HTTP::Server::Context, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil) env.response.content_type = "application/json" env.response.status_code = status_code + error_message = {"error" => message} + if additional_fields error_message = error_message.merge(additional_fields) end + return error_message.to_json end -def error_json_helper(env : HTTP::Server::Context, locale : String?, status_code : Int32, message : String) - error_json_helper(env, locale, status_code, message, nil) +def error_json_helper(env : HTTP::Server::Context, status_code : Int32, message : String) + error_json_helper(env, status_code, message, nil) end -def error_redirect_helper(env : HTTP::Server::Context, locale : String?) +# ------------------- +# Redirect +# ------------------- + +def error_redirect_helper(env : HTTP::Server::Context) request_path = env.request.path + locale = env.get("preferences").as(Preferences).locale + if request_path.starts_with?("/search") || request_path.starts_with?("/watch") || request_path.starts_with?("/channel") || request_path.starts_with?("/playlist?list=PL") next_steps_text = translate(locale, "next_steps_error_message") -- cgit v1.2.3 From 2d949834e976d433899341e46c72e7d8514ce50f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 19 Jan 2022 22:15:43 +0100 Subject: Make 'additional_fields' optional in JSON error template functions This allows us to de-duplicate functions --- src/invidious/helpers/errors.cr | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 1a1366fe..3acbac84 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -129,7 +129,12 @@ macro error_json(*args) error_json_helper(env, {{*args}}) end -def error_json_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception, additional_fields : Hash(String, Object) | Nil) +def error_json_helper( + env : HTTP::Server::Context, + status_code : Int32, + exception : Exception, + additional_fields : Hash(String, Object) | Nil = nil +) if exception.is_a?(InfoException) return error_json_helper(env, status_code, exception.message || "", additional_fields) end @@ -146,11 +151,12 @@ def error_json_helper(env : HTTP::Server::Context, status_code : Int32, exceptio return error_message.to_json end -def error_json_helper(env : HTTP::Server::Context, status_code : Int32, exception : Exception) - return error_json_helper(env, status_code, exception, nil) -end - -def error_json_helper(env : HTTP::Server::Context, status_code : Int32, message : String, additional_fields : Hash(String, Object) | Nil) +def error_json_helper( + env : HTTP::Server::Context, + status_code : Int32, + message : String, + additional_fields : Hash(String, Object) | Nil = nil +) env.response.content_type = "application/json" env.response.status_code = status_code @@ -163,10 +169,6 @@ def error_json_helper(env : HTTP::Server::Context, status_code : Int32, message return error_message.to_json end -def error_json_helper(env : HTTP::Server::Context, status_code : Int32, message : String) - error_json_helper(env, status_code, message, nil) -end - # ------------------- # Redirect # ------------------- -- cgit v1.2.3 From fa99c9aa85ac9f0b4dc0d705b8e62a50cd4cc9a3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 20 Jan 2022 16:00:47 +0100 Subject: Use '.dig?()' in playlist parsing --- src/invidious/playlists.cr | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index ecebba91..afbc8624 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -331,10 +331,10 @@ def fetch_playlist(plid : String) initial_data = YoutubeAPI.browse("VL" + plid, params: "") - playlist_sidebar_renderer = initial_data["sidebar"]?.try &.["playlistSidebarRenderer"]?.try &.["items"]? + playlist_sidebar_renderer = initial_data.dig?("sidebar", "playlistSidebarRenderer", "items") raise InfoException.new("Could not extract playlistSidebarRenderer.") if !playlist_sidebar_renderer - playlist_info = playlist_sidebar_renderer[0]["playlistSidebarPrimaryInfoRenderer"]? + playlist_info = playlist_sidebar_renderer.dig?(0, "playlistSidebarPrimaryInfoRenderer") raise InfoException.new("Could not extract playlist info") if !playlist_info title = playlist_info.dig?("title", "runs", 0, "text").try &.as_s || "" @@ -347,12 +347,15 @@ def fetch_playlist(plid : String) description_html = desc_item.try &.["runs"]?.try &.as_a .try { |run| content_to_comment_html(run).try &.to_s } || "

    " - thumbnail = playlist_info["thumbnailRenderer"]?.try &.["playlistVideoThumbnailRenderer"]? - .try &.["thumbnail"]["thumbnails"][0]["url"]?.try &.as_s + thumbnail = playlist_info.dig?( + "thumbnailRenderer", "playlistVideoThumbnailRenderer", + "thumbnail", "thumbnails", 0, "url" + ).try &.as_s views = 0_i64 updated = Time.utc video_count = 0 + playlist_info["stats"]?.try &.as_a.each do |stat| text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s next if !text @@ -371,12 +374,15 @@ def fetch_playlist(plid : String) author_thumbnail = "" ucid = "" else - author_info = playlist_sidebar_renderer[1]["playlistSidebarSecondaryInfoRenderer"]?.try &.["videoOwner"]["videoOwnerRenderer"]? + author_info = playlist_sidebar_renderer[1].dig?( + "playlistSidebarSecondaryInfoRenderer", "videoOwner", "videoOwnerRenderer" + ) + raise InfoException.new("Could not extract author info") if !author_info - author = author_info["title"]["runs"][0]["text"]?.try &.as_s || "" - author_thumbnail = author_info["thumbnail"]["thumbnails"][0]["url"]?.try &.as_s || "" - ucid = author_info["title"]["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"]?.try &.as_s || "" + author = author_info.dig?("title", "runs", 0, "text").try &.as_s || "" + author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url").try &.as_s || "" + ucid = author_info.dig?("title", "runs", 0, "navigationEndpoint", "browseEndpoint", "browseId").try &.as_s || "" end return Playlist.new({ -- cgit v1.2.3 From 4cd7a3e83f904f763497f214c52d52237d99228b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 20 Jan 2022 16:03:04 +0100 Subject: Remove useless 'locale = env.get...' from many routes --- src/invidious/routes/api/v1/authenticated.cr | 13 +------------ src/invidious/routes/api/v1/misc.cr | 3 --- src/invidious/routes/api/v1/videos.cr | 6 ------ src/invidious/routes/embed.cr | 3 --- 4 files changed, 1 insertion(+), 24 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 19c0ee3f..5d2b4c1c 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -115,8 +115,6 @@ module Invidious::Routes::API::V1::Authenticated end def self.list_playlists(env) - locale = env.get("preferences").as(Preferences).locale - env.response.content_type = "application/json" user = env.get("user").as(User) @@ -134,7 +132,6 @@ module Invidious::Routes::API::V1::Authenticated def self.create_playlist(env) env.response.content_type = "application/json" user = env.get("user").as(User) - locale = env.get("preferences").as(Preferences).locale title = env.params.json["title"]?.try &.as(String).delete("<>").byte_slice(0, 150) if !title @@ -160,8 +157,6 @@ module Invidious::Routes::API::V1::Authenticated end def self.update_playlist_attribute(env) - locale = env.get("preferences").as(Preferences).locale - env.response.content_type = "application/json" user = env.get("user").as(User) @@ -197,8 +192,6 @@ module Invidious::Routes::API::V1::Authenticated end def self.delete_playlist(env) - locale = env.get("preferences").as(Preferences).locale - env.response.content_type = "application/json" user = env.get("user").as(User) @@ -219,8 +212,6 @@ module Invidious::Routes::API::V1::Authenticated end def self.insert_video_into_playlist(env) - locale = env.get("preferences").as(Preferences).locale - env.response.content_type = "application/json" user = env.get("user").as(User) @@ -274,8 +265,6 @@ module Invidious::Routes::API::V1::Authenticated end def self.delete_video_in_playlist(env) - locale = env.get("preferences").as(Preferences).locale - env.response.content_type = "application/json" user = env.get("user").as(User) @@ -389,8 +378,8 @@ module Invidious::Routes::API::V1::Authenticated end def self.unregister_token(env) - locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" + user = env.get("user").as(User) scopes = env.get("scopes").as(Array(String)) diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index d10f4fcc..a1ce0cbc 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -1,7 +1,6 @@ module Invidious::Routes::API::V1::Misc # Stats API endpoint for Invidious def self.stats(env) - locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" if !CONFIG.statistics_enabled @@ -15,8 +14,6 @@ module Invidious::Routes::API::V1::Misc # user playlists and Invidious playlists. This means that we can't # reasonably split them yet. This should be addressed in APIv2 def self.get_playlist(env : HTTP::Server::Context) - locale = env.get("preferences").as(Preferences).locale - env.response.content_type = "application/json" plid = env.params.url["plid"] diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 3a013ba0..be0f699b 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -20,8 +20,6 @@ module Invidious::Routes::API::V1::Videos end def self.captions(env) - locale = env.get("preferences").as(Preferences).locale - env.response.content_type = "application/json" id = env.params.url["id"] @@ -149,8 +147,6 @@ module Invidious::Routes::API::V1::Videos # thumbnails for individual scenes in a video. # See https://support.jwplayer.com/articles/how-to-add-preview-thumbnails def self.storyboards(env) - locale = env.get("preferences").as(Preferences).locale - env.response.content_type = "application/json" id = env.params.url["id"] @@ -223,8 +219,6 @@ module Invidious::Routes::API::V1::Videos end def self.annotations(env) - locale = env.get("preferences").as(Preferences).locale - env.response.content_type = "text/xml" id = env.params.url["id"] diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 0e9701f0..207970b0 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -2,8 +2,6 @@ module Invidious::Routes::Embed def self.redirect(env) - locale = env.get("preferences").as(Preferences).locale - if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") begin playlist = get_playlist(plid) @@ -26,7 +24,6 @@ module Invidious::Routes::Embed end def self.show(env) - locale = env.get("preferences").as(Preferences).locale id = env.params.url["id"] plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") -- cgit v1.2.3 From 1c91110464e70e053282a73956754df97559d3d6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 20 Jan 2022 16:15:59 +0100 Subject: Fix some 'Lint/ShadowingOuterLocalVar' warnings reported by ameba --- src/invidious/routes/api/v1/authenticated.cr | 4 ++-- src/invidious/routes/api/v1/videos.cr | 6 +++--- src/invidious/routes/video_playback.cr | 24 ++++++++++++------------ 3 files changed, 17 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 5d2b4c1c..5da6143b 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -138,7 +138,7 @@ module Invidious::Routes::API::V1::Authenticated return error_json(400, "Invalid title.") end - privacy = env.params.json["privacy"]?.try { |privacy| PlaylistPrivacy.parse(privacy.as(String).downcase) } + privacy = env.params.json["privacy"]?.try { |p| PlaylistPrivacy.parse(p.as(String).downcase) } if !privacy return error_json(400, "Invalid privacy setting.") end @@ -175,7 +175,7 @@ module Invidious::Routes::API::V1::Authenticated 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 + privacy = env.params.json["privacy"]?.try { |p| PlaylistPrivacy.parse(p.as(String).downcase) } || playlist.privacy description = env.params.json["description"]?.try &.as(String).delete("\r") || playlist.description if title != playlist.title || diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index be0f699b..86eb26ee 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -71,9 +71,9 @@ module Invidious::Routes::API::V1::Videos env.response.content_type = "text/vtt; charset=UTF-8" if lang - caption = captions.select { |caption| caption.language_code == lang } + caption = captions.select(&.language_code.== lang) else - caption = captions.select { |caption| caption.name == label } + caption = captions.select(&.name.== label) end if caption.empty? @@ -179,7 +179,7 @@ module Invidious::Routes::API::V1::Videos env.response.content_type = "text/vtt" - storyboard = storyboards.select { |storyboard| width == "#{storyboard[:width]}" || height == "#{storyboard[:height]}" } + storyboard = storyboards.select { |sb| width == "#{sb[:width]}" || height == "#{sb[:height]}" } if storyboard.empty? haltf env, 404 diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 8a58b034..f6340c57 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -75,8 +75,8 @@ module Invidious::Routes::VideoPlayback end begin - client.get(url, headers) do |response| - response.headers.each do |key, value| + client.get(url, headers) do |resp| + resp.headers.each do |key, value| if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) env.response.headers[key] = value end @@ -84,7 +84,7 @@ module Invidious::Routes::VideoPlayback env.response.headers["Access-Control-Allow-Origin"] = "*" - if location = response.headers["Location"]? + if location = resp.headers["Location"]? location = URI.parse(location) location = "#{location.request_target}&host=#{location.host}" @@ -95,7 +95,7 @@ module Invidious::Routes::VideoPlayback return env.redirect location end - IO.copy(response.body_io, env.response) + IO.copy(resp.body_io, env.response) end rescue ex end @@ -132,15 +132,15 @@ module Invidious::Routes::VideoPlayback headers["Range"] = "bytes=#{chunk_start}-#{chunk_end}" begin - client.get(url, headers) do |response| + client.get(url, headers) do |resp| if first_chunk - if !env.request.headers["Range"]? && response.status_code == 206 + if !env.request.headers["Range"]? && resp.status_code == 206 env.response.status_code = 200 else - env.response.status_code = response.status_code + env.response.status_code = resp.status_code end - response.headers.each do |key, value| + resp.headers.each do |key, value| if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) && key.downcase != "content-range" env.response.headers[key] = value end @@ -148,7 +148,7 @@ module Invidious::Routes::VideoPlayback env.response.headers["Access-Control-Allow-Origin"] = "*" - if location = response.headers["Location"]? + if location = resp.headers["Location"]? location = URI.parse(location) location = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" @@ -161,8 +161,8 @@ module Invidious::Routes::VideoPlayback env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" end - if !response.headers.includes_word?("Transfer-Encoding", "chunked") - content_length = response.headers["Content-Range"].split("/")[-1].to_i64 + if !resp.headers.includes_word?("Transfer-Encoding", "chunked") + content_length = resp.headers["Content-Range"].split("/")[-1].to_i64 if env.request.headers["Range"]? env.response.headers["Content-Range"] = "bytes #{range_start}-#{range_end || (content_length - 1)}/#{content_length}" env.response.content_length = ((range_end.try &.+ 1) || content_length) - range_start @@ -172,7 +172,7 @@ module Invidious::Routes::VideoPlayback end end - proxy_file(response, env) + proxy_file(resp, env) end rescue ex if ex.message != "Error reading socket: Connection reset by peer" -- cgit v1.2.3 From 12b818a83ce119cc4b3943b01cf7d6353eaa664e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 20 Jan 2022 17:17:22 +0100 Subject: Fix more 'Lint/ShadowingOuterLocalVar' warnings reported by ameba --- src/invidious/channels/about.cr | 5 +++-- src/invidious/helpers/i18n.cr | 4 ++-- src/invidious/helpers/json_filter.cr | 4 ++-- src/invidious/helpers/static_file_handler.cr | 6 +++--- src/invidious/helpers/tokens.cr | 3 +++ src/invidious/helpers/utils.cr | 4 ++-- src/invidious/playlists.cr | 4 ++-- src/invidious/routes/api/manifest.cr | 2 +- src/invidious/routes/login.cr | 4 ++-- src/invidious/videos.cr | 4 ++-- 10 files changed, 22 insertions(+), 18 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 8cae7ae2..0f3928f5 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -96,7 +96,7 @@ def get_about_info(ucid, locale) : AboutChannel total_views = channel_about_meta["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. - joined = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, node| acc + node["text"].as_s } + joined = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, nd| acc + nd["text"].as_s } .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) # Normal Auto-generated channels @@ -136,7 +136,8 @@ def fetch_related_channels(about_channel : AboutChannel) : Array(AboutRelatedCha channels = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D") tabs = channels.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs").try(&.as_a?) || [] of JSON::Any - tab = tabs.find { |tab| tab.dig?("tabRenderer", "title").try(&.as_s?) == "Channels" } + tab = tabs.find(&.dig?("tabRenderer", "title").try(&.as_s?).try(&.== "Channels")) + return [] of AboutRelatedChannel if tab.nil? items = tab.dig?("tabRenderer", "content", "sectionListRenderer", "contents", 0, "itemSectionRenderer", "contents", 0, "gridRenderer", "items").try(&.as_a?) || [] of JSON::Any diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index e88e4491..3cf9ad1c 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -94,8 +94,8 @@ def translate(locale : String?, key : String, text : String | Nil = nil) : Strin translation = "" match_length = 0 - raw_data.as_h.each do |key, value| - if md = text.try &.match(/#{key}/) + raw_data.as_h.each do |hash_key, value| + if md = text.try &.match(/#{hash_key}/) if md[0].size >= match_length translation = value.as_s match_length = md[0].size diff --git a/src/invidious/helpers/json_filter.cr b/src/invidious/helpers/json_filter.cr index e4b57cea..b8e8f96d 100644 --- a/src/invidious/helpers/json_filter.cr +++ b/src/invidious/helpers/json_filter.cr @@ -98,9 +98,9 @@ module JSONFilter end end - group_name.split('/').each do |group_name| + group_name.split('/').each do |name| nest_stack.push({ - group_name: group_name, + group_name: name, closing_bracket_index: closing_bracket_index, }) end diff --git a/src/invidious/helpers/static_file_handler.cr b/src/invidious/helpers/static_file_handler.cr index 630c2fd2..6b3cd22e 100644 --- a/src/invidious/helpers/static_file_handler.cr +++ b/src/invidious/helpers/static_file_handler.cr @@ -175,9 +175,9 @@ module Kemal if @cached_files.sum(&.[1][:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT data = Bytes.new(size) - File.open(file_path) do |file| - file.read(data) - end + + File.open(file_path) { |f| f.read(data) } + filestat = File.info(file_path) @cached_files[file_path] = {data: data, filestat: filestat} diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index 8b076e39..9b664646 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -42,6 +42,9 @@ end def sign_token(key, hash) string_to_sign = [] of String + # TODO: figure out which "key" variable is used + # Ameba reports a warning for "Lint/ShadowingOuterLocalVar" on this + # variable, but its preferrable to not touch that (works fine atm). hash.each do |key, value| next if key == "signature" diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 09181c10..3ab9a0fc 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -292,8 +292,8 @@ def parse_range(range) end ranges = range.lchop("bytes=").split(',') - ranges.each do |range| - start_range, end_range = range.split('-') + ranges.each do |r| + start_range, end_range = r.split('-') start_range = start_range.to_i64? || 0_i64 end_range = end_range.to_i64? diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index afbc8624..177cee0f 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -202,8 +202,8 @@ struct InvidiousPlaylist end videos = get_playlist_videos(self, offset: offset, video_id: video_id) - videos.each_with_index do |video, index| - video.to_json(json, offset + index) + videos.each_with_index do |video, idx| + video.to_json(json, offset + idx) end end end diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index b6183001..ca429df5 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -98,7 +98,7 @@ module Invidious::Routes::API::Manifest height = fmt["height"].as_i # Resolutions reported by YouTube player (may not accurately reflect source) - height = potential_heights.min_by { |i| (height - i).abs } + height = potential_heights.min_by { |x| (height - x).abs } next if unique_res && heights.includes? height heights << height diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 64da3e4e..f4859e6f 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -425,9 +425,9 @@ module Invidious::Routes::Login found_valid_captcha = false error_exception = Exception.new - tokens.each do |token| + tokens.each do |tok| begin - validate_request(token, answer, env.request, HMAC_KEY, locale) + validate_request(tok, answer, env.request, HMAC_KEY, locale) found_valid_captcha = true rescue ex error_exception = ex diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 499ed94d..5cec1175 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -661,8 +661,8 @@ struct Video url = URI.parse(storyboards.shift) params = HTTP::Params.parse(url.query || "") - storyboards.each_with_index do |storyboard, i| - width, height, count, storyboard_width, storyboard_height, interval, _, sigh = storyboard.split("#") + storyboards.each_with_index do |sb, i| + width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#") params["sigh"] = sigh url.query = params.to_s -- cgit v1.2.3 From dee20f92a762048775699110a8cb320506d0084d Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 20 Jan 2022 17:18:17 +0100 Subject: Avoid infinite loop in ChannelVideo's to_xml/to_json methods --- src/invidious/channels/channels.cr | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 155ec559..46e34dd6 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -44,13 +44,9 @@ struct ChannelVideo end end - def to_json(locale, json : JSON::Builder | Nil = nil) - if json + def to_json(locale, _json : Nil = nil) + JSON.build do |json| to_json(locale, json) - else - JSON.build do |json| - to_json(locale, json) - end end end @@ -88,13 +84,9 @@ struct ChannelVideo end end - def to_xml(locale, xml : XML::Builder | Nil = nil) - if xml + def to_xml(locale, _xml : Nil = nil) + XML.build do |xml| to_xml(locale, xml) - else - XML.build do |xml| - to_xml(locale, xml) - end end end -- cgit v1.2.3 From 46f7ca9ffae8f2d00bb0ef4ad7a2c2dbd344d047 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 20 Jan 2022 18:05:16 +0100 Subject: Remove useless intermediary variable in youtube_api.cr This fixes an ameba warning --- src/invidious/yt_backend/youtube_api.cr | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 85239e72..426c076a 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -416,10 +416,9 @@ module YoutubeAPI # Send the POST request if {{ !flag?(:disable_quic) }} && CONFIG.use_quic # Using QUIC client - response = YT_POOL.client(client_config.proxy_region, + body = YT_POOL.client(client_config.proxy_region, &.post(url, headers: headers, body: data.to_json) - ) - body = response.body + ).body else # Using HTTP client body = YT_POOL.client(client_config.proxy_region) do |client| -- cgit v1.2.3 From 971b6ec96f2a99d8df4d9535d80d71b51868e077 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 20 Jan 2022 20:29:14 +0100 Subject: Fix 'Lint/UselessAssign' warnings reported by ameba --- src/invidious/comments.cr | 4 ---- src/invidious/playlists.cr | 1 - src/invidious/users.cr | 12 +++++------- 3 files changed, 5 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index dda92440..65f4b135 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -93,10 +93,6 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b end contents = body["contents"]? header = body["header"]? - if body["continuations"]? - # Removable? Doesn't seem like this is used. - more_replies_continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s - end else raise InfoException.new("Could not fetch comments") end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 177cee0f..88888a65 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -463,7 +463,6 @@ def extract_playlist_videos(initial_data : Hash(String, JSON::Any)) plid = i["navigationEndpoint"]["watchEndpoint"]["playlistId"].as_s index = i["navigationEndpoint"]["watchEndpoint"]["index"].as_i64 - thumbnail = i["thumbnail"]["thumbnails"][0]["url"].as_s title = i["title"].try { |t| t["simpleText"]? || t["runs"]?.try &.[0]["text"]? }.try &.as_s || "" author = i["shortBylineText"]?.try &.["runs"][0]["text"].as_s || "" ucid = i["shortBylineText"]?.try &.["runs"][0]["navigationEndpoint"]["browseEndpoint"]["browseId"].as_s || "" diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 49074994..213f5622 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -65,7 +65,6 @@ def fetch_user(sid, headers) feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) feed = XML.parse_html(feed.body) - channels = [] of String channels = feed.xpath_nodes(%q(//ul[@id="guide-channels"]/li/a)).compact_map do |channel| if {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? channel["title"] nil @@ -157,12 +156,11 @@ def generate_captcha(key) END_SVG - image = "" - convert = Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true, - input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe) do |proc| - image = proc.output.gets_to_end - image = Base64.strict_encode(image) - image = "data:image/png;base64,#{image}" + image = "data:image/png;base64," + image += Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true, + input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe + ) do |proc| + Base64.strict_encode(proc.output.gets_to_end) end answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}" -- cgit v1.2.3 From 84cc7322810e024bd0b5c0c05d752cc3476ad717 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 20 Jan 2022 21:44:18 +0100 Subject: search functions: Don't return result count This is useless, as the items count can be directly acessed using the '.size' method, so use that instead when needed. --- src/invidious/routes/api/v1/channels.cr | 2 +- src/invidious/routes/api/v1/search.cr | 2 +- src/invidious/routes/playlists.cr | 4 +--- src/invidious/routes/search.cr | 2 +- src/invidious/search.cr | 21 +++++++++------------ src/invidious/views/add_playlist_items.ecr | 2 +- src/invidious/views/search.ecr | 8 ++++---- 7 files changed, 18 insertions(+), 23 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 322ac42e..3e55b412 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -254,7 +254,7 @@ module Invidious::Routes::API::V1::Channels page = env.params.query["page"]?.try &.to_i? page ||= 1 - count, search_results = channel_search(query, page, ucid) + search_results = channel_search(query, page, ucid) JSON.build do |json| json.array do search_results.each do |item| diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index a3b6c795..0b0853b1 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -32,7 +32,7 @@ module Invidious::Routes::API::V1::Search return error_json(400, ex) end - count, search_results = search(query, search_params, region).as(Tuple) + search_results = search(query, search_params, region) JSON.build do |json| json.array do search_results.each do |item| diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 7a502a05..9c73874e 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -247,15 +247,13 @@ module Invidious::Routes::Playlists query = env.params.query["q"]? if query begin - search_query, count, items, operators = process_search_query(query, page, user, region: nil) + search_query, items, operators = process_search_query(query, page, user, region: nil) videos = items.select(SearchVideo).map(&.as(SearchVideo)) rescue ex videos = [] of SearchVideo - count = 0 end else videos = [] of SearchVideo - count = 0 end env.set "add_playlist_items", plid diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 5e606adf..3f4c7e5e 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -54,7 +54,7 @@ module Invidious::Routes::Search user = env.get? "user" begin - search_query, count, videos, operators = process_search_query(query, page, user, region: region) + search_query, videos, operators = process_search_query(query, page, user, region: region) rescue ex : ChannelSearchException return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.") rescue ex diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 0f6dc6eb..45d29059 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -5,7 +5,7 @@ class ChannelSearchException < InfoException end end -def channel_search(query, page, channel) +def channel_search(query, page, channel) : Array(SearchItem) response = YT_POOL.client &.get("/channel/#{channel}") if response.status_code == 404 @@ -24,7 +24,7 @@ def channel_search(query, page, channel) continuation_items = response_json["onResponseReceivedActions"]? .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - return 0, [] of SearchItem if !continuation_items + return [] of SearchItem if !continuation_items items = [] of SearchItem continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each { |item| @@ -32,17 +32,16 @@ def channel_search(query, page, channel) .try { |t| items << t } } - return items.size, items + return items end -def search(query, search_params = produce_search_params(content_type: "all"), region = nil) - return 0, [] of SearchItem if query.empty? +def search(query, search_params = produce_search_params(content_type: "all"), region = nil) : Array(SearchItem) + return [] of SearchItem if query.empty? client_config = YoutubeAPI::ClientConfig.new(region: region) initial_data = YoutubeAPI.search(query, search_params, client_config: client_config) - items = extract_items(initial_data) - return items.size, items + return extract_items(initial_data) end def produce_search_params(page = 1, sort : String = "relevance", date : String = "", content_type : String = "", @@ -217,7 +216,7 @@ def process_search_query(query, page, user, region) search_query = (query.split(" ") - operators).join(" ") if channel - count, items = channel_search(search_query, page, channel) + 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 ( @@ -227,16 +226,14 @@ def process_search_query(query, page, user, region) 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(page: page, sort: sort, date: date, content_type: content_type, duration: duration, features: features) - count, items = search(search_query, search_params, region).as(Tuple) + items = search(search_query, search_params, region) end # Light processing to flatten search results out of Categories. @@ -254,5 +251,5 @@ def process_search_query(query, page, user, region) end end - {search_query, items_without_category.size, items_without_category, operators} + {search_query, items_without_category, operators} end diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index c62861b0..ad50909a 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -48,7 +48,7 @@
    - <% if count >= 20 %> + <% if videos.size >= 20 %> <%= translate(locale, "Next page") %> diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index db374548..45bbdefc 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -5,7 +5,7 @@ <% search_query_encoded = env.get?("search").try { |x| URI.encode_www_form(x.as(String), space_to_plus: true) } %> -<% if count == 0 %> +<% if videos.size == 0 %>

    "><%= translate(locale, "Broken? Try another Invidious Instance!") %>

    @@ -98,7 +98,7 @@ <% end %> -<% if count == 0 %> +<% if videos.size == 0 %>
    <% else %>
    @@ -114,7 +114,7 @@
    - <% if count >= 20 %> + <% if videos.size >= 20 %> <%= translate(locale, "Next page") %> @@ -138,7 +138,7 @@
    - <% if count >= 20 %> + <% if videos.size >= 20 %> <%= translate(locale, "Next page") %> -- cgit v1.2.3 From 63e1165936339264981975614e189dbcad9f7f7e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 20 Jan 2022 22:22:48 +0100 Subject: videos.cr: use '.dig?()' where possible --- src/invidious/videos.cr | 45 +++++++++++++++++++++------------------------ 1 file changed, 21 insertions(+), 24 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 5cec1175..bdd7381c 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -497,7 +497,7 @@ struct Video end def length_seconds : Int32 - info["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["lengthSeconds"]?.try &.as_s.to_i || + info.dig?("microformat", "playerMicroformatRenderer", "lengthSeconds").try &.as_s.to_i || info["videoDetails"]["lengthSeconds"]?.try &.as_s.to_i || 0 end @@ -519,7 +519,9 @@ struct Video end def published : Time - info["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["publishDate"]?.try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc + info + .dig?("microformat", "playerMicroformatRenderer", "publishDate") + .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc end def published=(other : Time) @@ -545,8 +547,9 @@ struct Video end def premiere_timestamp : Time? - info["microformat"]?.try &.["playerMicroformatRenderer"]? - .try &.["liveBroadcastDetails"]?.try &.["startTimestamp"]?.try { |t| Time.parse_rfc3339(t.as_s) } + info + .dig?("microformat", "playerMicroformatRenderer", "liveBroadcastDetails", "startTimestamp") + .try { |t| Time.parse_rfc3339(t.as_s) } end def keywords @@ -558,8 +561,9 @@ struct Video end def allowed_regions - info["microformat"]?.try &.["playerMicroformatRenderer"]? - .try &.["availableCountries"]?.try &.as_a.map &.as_s || [] of String + info + .dig("microformat", "playerMicroformatRenderer", "availableCountries") + .try &.as_a.map &.as_s || [] of String end def author_thumbnail : String @@ -621,18 +625,11 @@ struct Video end def storyboards - storyboards = info["storyboards"]? - .try &.as_h - .try &.["playerStoryboardSpecRenderer"]? - .try &.["spec"]? - .try &.as_s.split("|") + storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec") + .try &.as_s.split("|") if !storyboards - if storyboard = info["storyboards"]? - .try &.as_h - .try &.["playerLiveStoryboardSpecRenderer"]? - .try &.["spec"]? - .try &.as_s + if storyboard = info.dig?("storyboards", "playerLiveStoryboardSpecRenderer", "spec").try &.as_s return [{ url: storyboard.split("#")[0], width: 106, @@ -690,9 +687,8 @@ struct Video end def paid - reason = info["playabilityStatus"]?.try &.["reason"]? - paid = reason == "This video requires payment to watch." ? true : false - paid + reason = info.dig?("playabilityStatus", "reason") || "" + return reason.includes? "requires payment" end def premium @@ -716,8 +712,9 @@ struct Video end def description - description = info["microformat"]?.try &.["playerMicroformatRenderer"]? - .try &.["description"]?.try &.["simpleText"]?.try &.as_s || "" + description = info! + .dig?("microformat", "playerMicroformatRenderer", "description", "simpleText") + .try &.as_s || "" end # TODO @@ -738,11 +735,11 @@ struct Video end def hls_manifest_url : String? - info["streamingData"]?.try &.["hlsManifestUrl"]?.try &.as_s + info.dig?("streamingData", "hlsManifestUrl").try &.as_s end def dash_manifest_url - info["streamingData"]?.try &.["dashManifestUrl"]?.try &.as_s + info.dig?("streamingData", "dashManifestUrl").try &.as_s end def genre : String @@ -758,7 +755,7 @@ struct Video end def is_family_friendly : Bool - info["microformat"]?.try &.["playerMicroformatRenderer"]["isFamilySafe"]?.try &.as_bool || false + info.dig?("microformat", "playerMicroformatRenderer", "isFamilySafe").try &.as_bool || false end def is_vr : Bool? -- cgit v1.2.3 From a82d21ff78787e24c331478c0fc71e6a8f4df980 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Fri, 21 Jan 2022 21:27:50 -0600 Subject: Cleanup channel helpers code --- src/invidious/channels/channels.cr | 24 ++++++++++-------------- src/invidious/database/channels.cr | 9 +-------- src/invidious/jobs/refresh_channels_job.cr | 2 +- src/invidious/routes/api/v1/authenticated.cr | 2 +- src/invidious/routes/preferences.cr | 10 +++++----- src/invidious/routes/subscriptions.cr | 2 +- src/invidious/users.cr | 2 +- 7 files changed, 20 insertions(+), 31 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 155ec559..b9f762e7 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -114,8 +114,9 @@ class ChannelRedirect < Exception end end -def get_batch_channels(channels, refresh = false, pull_all_videos = true, max_threads = 10) +def get_batch_channels(channels) finished_channel = Channel(String | Nil).new + max_threads = 10 spawn do active_threads = 0 @@ -130,7 +131,7 @@ def get_batch_channels(channels, refresh = false, pull_all_videos = true, max_th active_threads += 1 spawn do begin - get_channel(ucid, refresh, pull_all_videos) + get_channel(ucid) finished_channel.send(ucid) rescue ex finished_channel.send(nil) @@ -151,23 +152,18 @@ def get_batch_channels(channels, refresh = false, pull_all_videos = true, max_th return final end -def get_channel(id, refresh = true, pull_all_videos = true) - if channel = Invidious::Database::Channels.select(id) - if refresh && Time.utc - channel.updated > 10.minutes - channel = fetch_channel(id, pull_all_videos: pull_all_videos) - Invidious::Database::Channels.insert(channel, update_on_conflict: true) - end - else - channel = fetch_channel(id, pull_all_videos: pull_all_videos) - Invidious::Database::Channels.insert(channel) - end +def get_channel(id) + channel = Invidious::Database::Channels.select(id) + return channel if channel + channel = fetch_channel(id, pull_all_videos: false) + Invidious::Database::Channels.insert(channel) return channel end -def fetch_channel(ucid, pull_all_videos = true, locale = nil) +def fetch_channel(ucid, pull_all_videos : Bool) LOGGER.debug("fetch_channel: #{ucid}") - LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}, locale = #{locale}") + LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}") LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body diff --git a/src/invidious/database/channels.cr b/src/invidious/database/channels.cr index 134cf59d..e88b4989 100644 --- a/src/invidious/database/channels.cr +++ b/src/invidious/database/channels.cr @@ -10,7 +10,7 @@ module Invidious::Database::Channels # Insert / delete # ------------------- - def insert(channel : InvidiousChannel, update_on_conflict : Bool = false) + def insert(channel : InvidiousChannel) channel_array = channel.to_a request = <<-SQL @@ -18,13 +18,6 @@ module Invidious::Database::Channels VALUES (#{arg_array(channel_array)}) SQL - if update_on_conflict - request += <<-SQL - ON CONFLICT (id) DO UPDATE - SET author = $2, updated = $3 - SQL - end - PG_DB.exec(request, args: channel_array) end diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 941089c1..55fb8154 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -30,7 +30,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob spawn do begin LOGGER.trace("RefreshChannelsJob: #{id} fiber : Fetching channel") - channel = fetch_channel(id, CONFIG.full_refresh) + channel = fetch_channel(id, pull_all_videos: CONFIG.full_refresh) lim_fibers = max_fibers diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index fda655ef..4d0fe030 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -92,7 +92,7 @@ module Invidious::Routes::API::V1::Authenticated ucid = env.params.url["ucid"] if !user.subscriptions.includes? ucid - get_channel(ucid, false, false) + get_channel(ucid) Invidious::Database::Users.subscribe_channel(user, ucid) end diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index faae03bc..9c740cf2 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -327,7 +327,7 @@ module Invidious::Routes::PreferencesRoute user.subscriptions += body["subscriptions"].as_a.map(&.as_s) user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, false, false) + user.subscriptions = get_batch_channels(user.subscriptions) Invidious::Database::Users.update_subscriptions(user) end @@ -409,7 +409,7 @@ module Invidious::Routes::PreferencesRoute end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, false, false) + user.subscriptions = get_batch_channels(user.subscriptions) Invidious::Database::Users.update_subscriptions(user) when "import_freetube" @@ -418,7 +418,7 @@ module Invidious::Routes::PreferencesRoute end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, false, false) + user.subscriptions = get_batch_channels(user.subscriptions) Invidious::Database::Users.update_subscriptions(user) when "import_newpipe_subscriptions" @@ -437,7 +437,7 @@ module Invidious::Routes::PreferencesRoute end user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, false, false) + user.subscriptions = get_batch_channels(user.subscriptions) Invidious::Database::Users.update_subscriptions(user) when "import_newpipe" @@ -456,7 +456,7 @@ module Invidious::Routes::PreferencesRoute user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/")) user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions, false, false) + user.subscriptions = get_batch_channels(user.subscriptions) Invidious::Database::Users.update_subscriptions(user) diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr index 29152afb..ec8fe67b 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -51,7 +51,7 @@ module Invidious::Routes::Subscriptions case action when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id - get_channel(channel_id, false, false) + get_channel(channel_id) Invidious::Database::Users.subscribe_channel(user, channel_id) end when "action_remove_subscriptions" diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 49074994..a7ee72a9 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -74,7 +74,7 @@ def fetch_user(sid, headers) end end - channels = get_batch_channels(channels, false, false) + channels = get_batch_channels(channels) email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"])) if email -- cgit v1.2.3 From e92b3779ad6ac530b4979dfeccb66e96d75d14c9 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Mon, 24 Jan 2022 23:14:13 -0600 Subject: Add back in refreshing of channels every 2 days --- src/invidious/channels/channels.cr | 10 ++++++---- src/invidious/database/channels.cr | 9 ++++++++- 2 files changed, 14 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index b9f762e7..6905b6f8 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -152,12 +152,14 @@ def get_batch_channels(channels) return final end -def get_channel(id) +def get_channel(id) : InvidiousChannel channel = Invidious::Database::Channels.select(id) - return channel if channel - channel = fetch_channel(id, pull_all_videos: false) - Invidious::Database::Channels.insert(channel) + if channel.nil? || (Time.utc - channel.updated) > 2.days + channel = fetch_channel(id, pull_all_videos: false) + Invidious::Database::Channels.insert(channel, update_on_conflict: true) + end + return channel end diff --git a/src/invidious/database/channels.cr b/src/invidious/database/channels.cr index e88b4989..134cf59d 100644 --- a/src/invidious/database/channels.cr +++ b/src/invidious/database/channels.cr @@ -10,7 +10,7 @@ module Invidious::Database::Channels # Insert / delete # ------------------- - def insert(channel : InvidiousChannel) + def insert(channel : InvidiousChannel, update_on_conflict : Bool = false) channel_array = channel.to_a request = <<-SQL @@ -18,6 +18,13 @@ module Invidious::Database::Channels VALUES (#{arg_array(channel_array)}) SQL + if update_on_conflict + request += <<-SQL + ON CONFLICT (id) DO UPDATE + SET author = $2, updated = $3 + SQL + end + PG_DB.exec(request, args: channel_array) end -- cgit v1.2.3 From 6f4665588f0bb096576221d20fbfc2dd758d7b96 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 20 Jan 2022 22:23:21 +0100 Subject: search.cr: use do/end rather than inline {} block --- src/invidious/search.cr | 7 +++---- src/invidious/videos.cr | 4 ++-- 2 files changed, 5 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/search.cr b/src/invidious/search.cr index 45d29059..d8971e79 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -27,10 +27,9 @@ def channel_search(query, page, channel) : Array(SearchItem) return [] of SearchItem if !continuation_items items = [] of SearchItem - continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each { |item| - extract_item(item["itemSectionRenderer"]["contents"].as_a[0]) - .try { |t| items << t } - } + continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each do |item| + extract_item(item["itemSectionRenderer"]["contents"].as_a[0]).try { |t| items << t } + end return items end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index bdd7381c..d77d56d2 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -687,7 +687,7 @@ struct Video end def paid - reason = info.dig?("playabilityStatus", "reason") || "" + reason = info.dig?("playabilityStatus", "reason").try &.as_s || "" return reason.includes? "requires payment" end @@ -712,7 +712,7 @@ struct Video end def description - description = info! + description = info .dig?("microformat", "playerMicroformatRenderer", "description", "simpleText") .try &.as_s || "" end -- cgit v1.2.3 From 519c227c4f4df81522e38b34e6a5ddfdb73f0def Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 29 Jan 2022 13:43:26 +0100 Subject: Use short syntax for 'File.open' block --- src/invidious/helpers/static_file_handler.cr | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/static_file_handler.cr b/src/invidious/helpers/static_file_handler.cr index 6b3cd22e..6ef2d74c 100644 --- a/src/invidious/helpers/static_file_handler.cr +++ b/src/invidious/helpers/static_file_handler.cr @@ -175,8 +175,7 @@ module Kemal if @cached_files.sum(&.[1][:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT data = Bytes.new(size) - - File.open(file_path) { |f| f.read(data) } + File.open(file_path, &.read(data)) filestat = File.info(file_path) -- cgit v1.2.3 From 4e44a91d08e45103536235030407c871b3ec5082 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 1 Feb 2022 21:40:06 +0100 Subject: Add support for clips --- src/invidious.cr | 1 + src/invidious/routes/watch.cr | 15 +++++++++++++++ 2 files changed, 16 insertions(+) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index b09f31c2..f4cae7ea 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -339,6 +339,7 @@ end Invidious::Routing.post "/watch_ajax", Invidious::Routes::Watch, :mark_watched Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect + Invidious::Routing.get "/clip/:clip", Invidious::Routes::Watch, :clip Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 7d048ce8..42bc4219 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -266,4 +266,19 @@ module Invidious::Routes::Watch "{}" end end + + def self.clip(env) + clip_id = env.params.url["clip"]? + + return error_template(400, "A clip ID is required") if !clip_id + + response = YoutubeAPI.resolve_url("https://www.youtube.com/clip/#{clip_id}") + return error_template(400, "Invalid clip ID") if response["error"]? + + if video_id = response.dig?("endpoint", "watchEndpoint", "videoId") + return env.redirect "/watch?v=#{video_id}&#{env.params.query}" + else + return error_template(404, "The requested clip doesn't exist") + end + end end -- cgit v1.2.3 From 6ddbccbc958d7925951ab6a1d035b3764f402759 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Feb 2022 00:02:24 +0100 Subject: Add new exception for parsing issues --- src/invidious/exceptions.cr | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/invidious/exceptions.cr (limited to 'src') diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr new file mode 100644 index 00000000..391a574d --- /dev/null +++ b/src/invidious/exceptions.cr @@ -0,0 +1,8 @@ +# Exception used to hold the name of the missing item +# Should be used in all parsing functions +class BrokenTubeException < InfoException + getter element : String + + def initialize(@element) + end +end -- cgit v1.2.3 From 99091e919c9af56c27ca8aebd790c3b64b564f78 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Feb 2022 00:10:32 +0100 Subject: video parsing: raise if major root element is missing --- src/invidious/videos.cr | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d77d56d2..b0d8b4d1 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -885,16 +885,24 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ # Top level elements - primary_results = player_response - .dig?("contents", "twoColumnWatchNextResults", "results", "results", "contents") + main_results = player_response.dig?("contents", "twoColumnWatchNextResults") + + raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results + + primary_results = main_results.dig?("results", "results", "contents") + + raise BrokenTubeException.new("results") if !primary_results video_primary_renderer = primary_results - .try &.as_a.find(&.["videoPrimaryInfoRenderer"]?) - .try &.["videoPrimaryInfoRenderer"] + .as_a.find(&.["videoPrimaryInfoRenderer"]?) + .try &.["videoPrimaryInfoRenderer"] video_secondary_renderer = primary_results - .try &.as_a.find(&.["videoSecondaryInfoRenderer"]?) - .try &.["videoSecondaryInfoRenderer"] + .as_a.find(&.["videoSecondaryInfoRenderer"]?) + .try &.["videoSecondaryInfoRenderer"] + + raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer + raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer # Likes/dislikes -- cgit v1.2.3 From d7ebd763f54a3211aac02a2862775bf130029061 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Feb 2022 00:11:05 +0100 Subject: video parsing: add secondary_results root element --- src/invidious/videos.cr | 3 +++ 1 file changed, 3 insertions(+) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index b0d8b4d1..76f7123a 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -890,8 +890,11 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results primary_results = main_results.dig?("results", "results", "contents") + secondary_results = main_results + .dig?("secondaryResults", "secondaryResults", "results") raise BrokenTubeException.new("results") if !primary_results + raise BrokenTubeException.new("secondaryResults") if !secondary_results video_primary_renderer = primary_results .as_a.find(&.["videoPrimaryInfoRenderer"]?) -- cgit v1.2.3 From e6ddd6d6c1f649f43c5906f1090d800f619f37fd Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Feb 2022 00:44:52 +0100 Subject: make HelperExtractors non-private --- src/invidious/yt_backend/extractors.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 66b3cdef..27ce550b 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -505,7 +505,7 @@ end # # Mostly used to extract out repeated structures to deal with code # repetition. -private module HelperExtractors +module HelperExtractors # Retrieves the amount of videos present within the given InnerTube data. # # Returns a 0 when it's unable to do so -- cgit v1.2.3 From 9621175dc91d8f410dbc14d09bc0132e6a33ae6d Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Feb 2022 00:57:44 +0100 Subject: extractors: Add helper for short view count text --- src/invidious/yt_backend/extractors.cr | 14 ++++++++++++++ 1 file changed, 14 insertions(+) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 27ce550b..41d95962 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -519,6 +519,20 @@ module HelperExtractors end end + # Retrieves the amount of views/viewers a video has. + # Seems to be used on related videos only + # + # Returns "0" when unable to parse + def self.get_short_view_count(container : JSON::Any) : String + box = container["shortViewCountText"]? + return "0" if !box + + # Simpletext: "4M views" + # runs: {"text": "1.1K"},{"text":" watching"} + return box["simpleText"]?.try &.as_s.sub(" views", "") || + box.dig?("runs", 0, "text").try &.as_s || "0" + end + # Retrieve lowest quality thumbnail from InnerTube data # # TODO allow configuration of image quality (-1 is highest) -- cgit v1.2.3 From f124e8cf93ebc745777d1e36b4563e3de2cfad8a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Feb 2022 01:36:42 +0100 Subject: Fix parsing of related videos --- src/invidious/videos.cr | 105 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 76 insertions(+), 29 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 76f7123a..d8289506 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -802,23 +802,50 @@ class VideoRedirect < Exception end end -def parse_related(r : JSON::Any) : JSON::Any? - # TODO: r["endScreenPlaylistRenderer"], etc. - return if !r["endScreenVideoRenderer"]? - r = r["endScreenVideoRenderer"].as_h - - return if !r["lengthInSeconds"]? - - rv = {} of String => JSON::Any - rv["author"] = r["shortBylineText"]["runs"][0]?.try &.["text"] || JSON::Any.new("") - rv["ucid"] = r["shortBylineText"]["runs"][0]?.try &.["navigationEndpoint"]["browseEndpoint"]["browseId"] || JSON::Any.new("") - rv["author_url"] = JSON::Any.new("/channel/#{rv["ucid"]}") - rv["length_seconds"] = JSON::Any.new(r["lengthInSeconds"].as_i.to_s) - rv["title"] = r["title"]["simpleText"] - rv["short_view_count_text"] = JSON::Any.new(r["shortViewCountText"]?.try &.["simpleText"]?.try &.as_s || "") - rv["view_count"] = JSON::Any.new(r["title"]["accessibility"]?.try &.["accessibilityData"]["label"].as_s.match(/(?[1-9](\d+,?)*) views/).try &.["views"].gsub(/\D/, "") || "") - rv["id"] = r["videoId"] - JSON::Any.new(rv) +# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer". +# The former is preferred as it has more videos in it. The second has +# the same 11 first entries as the compact rendered. +# +# TODO: "compactRadioRenderer" (Mix) and +def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? + return nil if !related["videoId"]? + + # The compact renderer has video length in seconds, where the end + # screen rendered has a full text version ("42:40") + length = related["lengthInSeconds"]?.try &.as_i.to_s + length ||= related.dig?("lengthText", "simpleText").try do |box| + decode_length_seconds(box.as_s).to_s + end + + # Both have "short", so the "long" option shouldn't be required + channel_info = (related["shortBylineText"]? || related["longBylineText"]?) + .try &.dig?("runs", 0) + + author = channel_info.try &.dig?("text") + ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } + + # "4,088,033 views", only available on compact renderer + # and when video is not a livestream + view_count = related.dig?("viewCountText", "simpleText") + .try &.as_s.gsub(/\D/, "") + + short_view_count = related.try do |r| + HelperExtractors.get_short_view_count(r).to_s + end + + LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") + + # TODO: when refactoring video types, make a struct for related videos + # or reuse an existing type, if that fits. + return { + "id" => related["videoId"], + "title" => related["title"]["simpleText"], + "author" => author || JSON::Any.new(""), + "ucid" => JSON::Any.new(ucid || ""), + "length_seconds" => JSON::Any.new(length || "0"), + "view_count" => JSON::Any.new(view_count || "0"), + "short_view_count" => JSON::Any.new(short_view_count || "0"), + } end def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) @@ -871,18 +898,6 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ params[f] = player_response[f] if player_response[f]? end - params["relatedVideos"] = ( - player_response - .dig?("playerOverlays", "playerOverlayRenderer", "endScreen", "watchNextEndScreenRenderer", "results") - .try &.as_a.compact_map { |r| parse_related r } || \ - player_response - .dig?("webWatchNextResponseExtensionData", "relatedVideoArgs") - .try &.as_s.split(",").map { |r| - r = HTTP::Params.parse(r).to_h - JSON::Any.new(Hash.zip(r.keys, r.values.map { |v| JSON::Any.new(v) })) - } - ).try { |a| JSON::Any.new(a) } || JSON::Any.new([] of JSON::Any) - # Top level elements main_results = player_response.dig?("contents", "twoColumnWatchNextResults") @@ -907,6 +922,38 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer + # Related videos + + LOGGER.debug("extract_video_info: parsing related videos...") + + related = [] of JSON::Any + + # Parse "compactVideoRenderer" items (under secondary results) + secondary_results.as_a.each do |element| + if item = element["compactVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + + # If nothing was found previously, fall back to end screen renderer + if related.empty? + # Container for "endScreenVideoRenderer" items + player_overlays = player_response.dig?( + "playerOverlays", "playerOverlayRenderer", + "endScreen", "watchNextEndScreenRenderer", "results" + ) + + secondary_results.try &.as_a.each do |element| + if item = element["endScreenVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + end + + params["relatedVideos"] = JSON::Any.new(related) + # Likes/dislikes toplevel_buttons = video_primary_renderer -- cgit v1.2.3 From 1ec15dc0736c193330ae9e1eeaff2ae3a5f9e890 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Feb 2022 01:44:11 +0100 Subject: Propagate related videos changes to API function --- src/invidious/videos.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d8289506..74e2746c 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -446,7 +446,7 @@ struct Video end json.field "author", rv["author"] - json.field "authorUrl", rv["author_url"]? + json.field "authorUrl", "/channel/#{rv["ucid"]?}" json.field "authorId", rv["ucid"]? if rv["author_thumbnail"]? json.field "authorThumbnails" do @@ -455,7 +455,7 @@ struct Video qualities.each do |quality| json.object do - json.field "url", rv["author_thumbnail"]?.try &.gsub(/s\d+-/, "s#{quality}-") + json.field "url", rv["author_thumbnail"].try &.gsub(/s\d+-/, "s#{quality}-") json.field "width", quality json.field "height", quality end @@ -465,7 +465,7 @@ struct Video end json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i - json.field "viewCountText", rv["short_view_count_text"]? + json.field "viewCountText", rv["short_view_count"]? json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 end end -- cgit v1.2.3 From cb0f7bf6b0ae8427e7ef96e9eacfcff79c28232d Mon Sep 17 00:00:00 2001 From: mastihios <91783447+mastihios@users.noreply.github.com> Date: Mon, 11 Oct 2021 20:35:07 +0000 Subject: Change encoding to HTML.escape --- src/invidious/views/authorize_token.ecr | 2 +- src/invidious/views/change_password.ecr | 2 +- src/invidious/views/clear_watch_history.ecr | 2 +- src/invidious/views/components/item.ecr | 6 +++--- src/invidious/views/components/subscribe_widget.ecr | 4 ++-- src/invidious/views/create_playlist.ecr | 2 +- src/invidious/views/delete_account.ecr | 2 +- src/invidious/views/delete_playlist.ecr | 2 +- src/invidious/views/edit_playlist.ecr | 2 +- src/invidious/views/login.ecr | 4 ++-- src/invidious/views/subscription_manager.ecr | 2 +- src/invidious/views/template.ecr | 2 +- src/invidious/views/token_manager.ecr | 2 +- 13 files changed, 17 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/invidious/views/authorize_token.ecr b/src/invidious/views/authorize_token.ecr index 2dc948d9..725f392e 100644 --- a/src/invidious/views/authorize_token.ecr +++ b/src/invidious/views/authorize_token.ecr @@ -72,7 +72,7 @@ <% end %> - +
    <% end %> diff --git a/src/invidious/views/change_password.ecr b/src/invidious/views/change_password.ecr index fb558f1d..1b9eb82e 100644 --- a/src/invidious/views/change_password.ecr +++ b/src/invidious/views/change_password.ecr @@ -23,7 +23,7 @@ <%= translate(locale, "Change password") %> - +

    diff --git a/src/invidious/views/clear_watch_history.ecr b/src/invidious/views/clear_watch_history.ecr index 5f9d1032..c9acbe44 100644 --- a/src/invidious/views/clear_watch_history.ecr +++ b/src/invidious/views/clear_watch_history.ecr @@ -19,6 +19,6 @@
    - + diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 5a93d802..5f8bde13 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -54,7 +54,7 @@ <% if plid = env.get?("remove_playlist_items") %>
    " method="post"> - "> + ">

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

    " method="post"> - "> + "> diff --git a/src/invidious/views/create_playlist.ecr b/src/invidious/views/create_playlist.ecr index 14f3673e..807244e6 100644 --- a/src/invidious/views/create_playlist.ecr +++ b/src/invidious/views/create_playlist.ecr @@ -30,7 +30,7 @@ - + diff --git a/src/invidious/views/delete_account.ecr b/src/invidious/views/delete_account.ecr index 9103d5b7..67351bbf 100644 --- a/src/invidious/views/delete_account.ecr +++ b/src/invidious/views/delete_account.ecr @@ -19,6 +19,6 @@ - + diff --git a/src/invidious/views/delete_playlist.ecr b/src/invidious/views/delete_playlist.ecr index 480e36f4..cd66b963 100644 --- a/src/invidious/views/delete_playlist.ecr +++ b/src/invidious/views/delete_playlist.ecr @@ -19,6 +19,6 @@ - + diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr index 308bd677..89819ef0 100644 --- a/src/invidious/views/edit_playlist.ecr +++ b/src/invidious/views/edit_playlist.ecr @@ -41,7 +41,7 @@

    - + <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> diff --git a/src/invidious/views/login.ecr b/src/invidious/views/login.ecr index e2963e9f..01d7a210 100644 --- a/src/invidious/views/login.ecr +++ b/src/invidious/views/login.ecr @@ -66,7 +66,7 @@ <% captcha = captcha.not_nil! %> <% captcha[:tokens].each_with_index do |token, i| %> - + <% end %> @@ -74,7 +74,7 @@ <% else # "text" %> <% captcha = captcha.not_nil! %> <% captcha[:tokens].each_with_index do |token, i| %> - + <% end %> diff --git a/src/invidious/views/subscription_manager.ecr b/src/invidious/views/subscription_manager.ecr index 5fa7d203..c2a89ca2 100644 --- a/src/invidious/views/subscription_manager.ecr +++ b/src/invidious/views/subscription_manager.ecr @@ -38,7 +38,7 @@

    " method="post"> - "> + "> "> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 240b523a..92df1272 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -72,7 +72,7 @@ <% end %>
    " method="post"> - "> + "> "> diff --git a/src/invidious/views/token_manager.ecr b/src/invidious/views/token_manager.ecr index 12e0e8c9..79f905a1 100644 --- a/src/invidious/views/token_manager.ecr +++ b/src/invidious/views/token_manager.ecr @@ -30,7 +30,7 @@

    " method="post"> - "> + "> "> -- cgit v1.2.3 From eca8d2e7d72d142c7509f2f6cfb8f96a915bb77d Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Feb 2022 03:55:43 +0100 Subject: Apply suggestions from code review Co-authored-by: Matthew McGarvey --- src/invidious/videos.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 74e2746c..446e8e03 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -455,7 +455,7 @@ struct Video qualities.each do |quality| json.object do - json.field "url", rv["author_thumbnail"].try &.gsub(/s\d+-/, "s#{quality}-") + json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") json.field "width", quality json.field "height", quality end @@ -944,7 +944,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ "endScreen", "watchNextEndScreenRenderer", "results" ) - secondary_results.try &.as_a.each do |element| + player_overlays.try &.as_a.each do |element| if item = element["endScreenVideoRenderer"]? related_video = parse_related_video(item) related << JSON::Any.new(related_video) if related_video -- cgit v1.2.3 From ba37259258277aafb6fc700dabecead695bd624e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Feb 2022 04:24:31 +0100 Subject: Also propagate changes to watch ECR page --- src/invidious/views/watch.ecr | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 00f5f8b7..2e0aee99 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -321,11 +321,11 @@ we're going to need to do it here in order to allow for translations.

    - <% if views = rv["short_view_count_text"]?.try &.delete(", views watching") %> - <% if !views.empty? %> - <%= translate_count(locale, "generic_views_count", views.to_i? || 0) %> - <% end %> - <% end %> + <%= + views = rv["view_count"]?.try &.to_i? + views ||= rv["view_count_short"]?.try { |x| short_text_to_number(x) } + translate_count(locale, "generic_views_count", views || 0, NumberFormatting::Short) + %>

    -- cgit v1.2.3 From 66340281e6fd8dde05b9306ccd5eaca574b99533 Mon Sep 17 00:00:00 2001 From: jonas-w Date: Thu, 3 Feb 2022 21:42:28 +0100 Subject: Added verification badge for channel view --- src/invidious/channels/about.cr | 13 +++++++++---- src/invidious/views/channel.ecr | 3 +++ src/invidious/views/community.ecr | 3 +++ src/invidious/views/playlists.ecr | 3 +++ 4 files changed, 18 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 0f3928f5..f92681a7 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -12,7 +12,8 @@ record AboutChannel, joined : Time, is_family_friendly : Bool, allowed_regions : Array(String), - tabs : Array(String) + tabs : Array(String), + verified : Bool record AboutRelatedChannel, ucid : String, @@ -41,7 +42,7 @@ def get_about_info(ucid, locale) : AboutChannel if !initdata.has_key?("metadata") auto_generated = true end - + verified = false if auto_generated author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s @@ -62,7 +63,7 @@ def get_about_info(ucid, locale) : AboutChannel author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s - + # Raises a KeyError on failure. banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? banner = banners.try &.[-1]?.try &.["url"].as_s? @@ -70,7 +71,10 @@ def get_about_info(ucid, locale) : AboutChannel # if banner.includes? "channels/c4/default_banner" # banner = nil # end - + badges = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["badges"]? + if !badges.nil? + verified=true + end description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" description_html = HTML.escape(description) @@ -128,6 +132,7 @@ def get_about_info(ucid, locale) : AboutChannel is_family_friendly: is_family_friendly, allowed_regions: allowed_regions, tabs: tabs, + verified: verified, ) end diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 40b553a9..f14546a5 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -21,6 +21,9 @@
    <%= author %> + <% if channel.verified %> + + <% end %>
    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index f0add06b..bb4994d2 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -20,6 +20,9 @@
    <%= author %> + <% if channel.verified %> + + <% end %>
    diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 12dba088..df9bc76d 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -20,6 +20,9 @@
    <%= author %> + <% if channel.verified %> + + <% end %>
    -- cgit v1.2.3 From c584e31657770e206a583972607ca0833fa42c56 Mon Sep 17 00:00:00 2001 From: jonas-w Date: Thu, 3 Feb 2022 22:14:00 +0100 Subject: Inlined the if statement --- src/invidious/views/channel.ecr | 5 +---- src/invidious/views/community.ecr | 5 +---- src/invidious/views/playlists.ecr | 5 +---- 3 files changed, 3 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index f14546a5..a32a2eed 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -20,10 +20,7 @@
    - <%= author %> - <% if channel.verified %> - - <% end %> + <%= author %><% if channel.verified %><% end %>
    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index bb4994d2..7b002f04 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -19,10 +19,7 @@
    - <%= author %> - <% if channel.verified %> - - <% end %> + <%= author %><% if channel.verified %><% end %>
    diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index df9bc76d..63badf76 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -19,10 +19,7 @@
    - <%= author %> - <% if channel.verified %> - - <% end %> + <%= author %><% if channel.verified %><% end %>
    -- cgit v1.2.3 From 154bca463554c4305cf616df1e65abbf30136019 Mon Sep 17 00:00:00 2001 From: jonas-w Date: Thu, 3 Feb 2022 22:32:00 +0100 Subject: Added Verification Badge to Youtube Comments --- src/invidious/comments.cr | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 65f4b135..6febbe45 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -144,7 +144,8 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b content_html = node_comment["contentText"]?.try { |t| parse_content(t) } || "" author = node_comment["authorText"]?.try &.["simpleText"]? || "" - + verified = node_comment["authorCommentBadge"]? != nil + json.field "verified", verified json.field "author", author json.field "authorThumbnails" do json.array do @@ -328,7 +329,9 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end author_name = HTML.escape(child["author"].as_s) - + if child["verified"].as_bool + author_name += "" + end html << <<-END_HTML
    -- cgit v1.2.3 From 1fee636afae81b45ee433b54077b41b4baf291ea Mon Sep 17 00:00:00 2001 From: jonas-w Date: Thu, 3 Feb 2022 23:18:50 +0100 Subject: Added verification badge to video player and error with related_videos --- src/invidious/channels/about.cr | 4 ++-- src/invidious/videos.cr | 8 +++++++- src/invidious/views/watch.ecr | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index f92681a7..6114e8af 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -63,7 +63,7 @@ def get_about_info(ucid, locale) : AboutChannel author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s - + # Raises a KeyError on failure. banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? banner = banners.try &.[-1]?.try &.["url"].as_s? @@ -73,7 +73,7 @@ def get_about_info(ucid, locale) : AboutChannel # end badges = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["badges"]? if !badges.nil? - verified=true + verified = true end description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" description_html = HTML.escape(description) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 446e8e03..a5ecdeea 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -570,6 +570,10 @@ struct Video info["authorThumbnail"]?.try &.as_s || "" end + def author_verified : Bool + info["authorVerified"].as_bool + end + def sub_count_text : String info["subCountText"]?.try &.as_s || "-" end @@ -822,6 +826,7 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? .try &.dig?("runs", 0) author = channel_info.try &.dig?("text") + authorVerified = channel_info.try &.dig?("ownerBadges") != nil ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } # "4,088,033 views", only available on compact renderer @@ -845,6 +850,7 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? "length_seconds" => JSON::Any.new(length || "0"), "view_count" => JSON::Any.new(view_count || "0"), "short_view_count" => JSON::Any.new(short_view_count || "0"), + "author_verified" => JSON::Any.new(authorVerified), } end @@ -1037,7 +1043,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url") - + params["authorVerified"] = JSON::Any.new(author_info.try &.["badges"]? != nil) params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 2e0aee99..8422fce0 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -240,7 +240,7 @@ we're going to need to do it here in order to allow for translations. <% if !video.author_thumbnail.empty? %> <% end %> - <%= author %> + <%= author %><% if video.author_verified %><% end %>
    -- cgit v1.2.3 From a2578ac6b4a3ab370e2d1d483539ce9d32c272ca Mon Sep 17 00:00:00 2001 From: jonas-w Date: Fri, 4 Feb 2022 17:55:22 +0100 Subject: Added Verified Badge to related videos --- src/invidious/videos.cr | 8 ++++++-- src/invidious/views/watch.ecr | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index a5ecdeea..69468b5e 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -826,7 +826,11 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? .try &.dig?("runs", 0) author = channel_info.try &.dig?("text") - authorVerified = channel_info.try &.dig?("ownerBadges") != nil + author_verified_badge = related["ownerBadges"]?.try do |badges_array| + badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") + end + + author_verified = (author_verified_badge && author_verified_badge.size > 0).to_s ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } # "4,088,033 views", only available on compact renderer @@ -850,7 +854,7 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? "length_seconds" => JSON::Any.new(length || "0"), "view_count" => JSON::Any.new(view_count || "0"), "short_view_count" => JSON::Any.new(short_view_count || "0"), - "author_verified" => JSON::Any.new(authorVerified), + "author_verified" => JSON::Any.new(author_verified), } end diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 8422fce0..496ceddc 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -314,9 +314,9 @@ we're going to need to do it here in order to allow for translations.
    <% if rv["ucid"]? %> - "><%= rv["author"]? %> + "><%= rv["author"]? %><% if rv["author_verified"].== "true" %><% end %> <% else %> - <%= rv["author"]? %> + <%= rv["author"]? %><% if rv["author_verified"].== "true" %><% end %> <% end %>
    -- cgit v1.2.3 From 00df3e2c4034d99701091c0beefa6133f57b275c Mon Sep 17 00:00:00 2001 From: jonas-w Date: Fri, 4 Feb 2022 19:59:07 +0100 Subject: Refactored code and added badges to Search but many dummies because of the way components/item works --- src/invidious/channels/about.cr | 11 +++---- src/invidious/channels/channels.cr | 3 ++ src/invidious/comments.cr | 6 ++-- src/invidious/helpers/serialized_yt_data.cr | 7 +++-- src/invidious/mixes.cr | 4 +++ src/invidious/playlists.cr | 6 ++++ src/invidious/routes/feeds.cr | 2 ++ src/invidious/videos.cr | 4 ++- src/invidious/views/components/item.ecr | 8 ++--- src/invidious/yt_backend/extractors.cr | 48 ++++++++++++++++++++--------- 10 files changed, 69 insertions(+), 30 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 6114e8af..97b45df5 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -42,7 +42,7 @@ def get_about_info(ucid, locale) : AboutChannel if !initdata.has_key?("metadata") auto_generated = true end - verified = false + if auto_generated author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s @@ -71,10 +71,9 @@ def get_about_info(ucid, locale) : AboutChannel # if banner.includes? "channels/c4/default_banner" # banner = nil # end - badges = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["badges"]? - if !badges.nil? - verified = true - end + author_verified_badges = initdata["header"]?.try &.["c4TabbedHeaderRenderer"]?.try &.["badges"]? + + author_verified = (author_verified_badges && author_verified_badges.size > 0) description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" description_html = HTML.escape(description) @@ -132,7 +131,7 @@ def get_about_info(ucid, locale) : AboutChannel is_family_friendly: is_family_friendly, allowed_regions: allowed_regions, tabs: tabs, - verified: verified, + verified: author_verified || false, ) end diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index e0459cc3..9e701043 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -21,6 +21,7 @@ struct ChannelVideo property live_now : Bool = false property premiere_timestamp : Time? = nil property views : Int64? = nil + property author_verified : Bool #TODO currently a dummy def to_json(locale, json : JSON::Builder) json.object do @@ -218,6 +219,7 @@ def fetch_channel(ucid, pull_all_videos : Bool) live_now: live_now, premiere_timestamp: premiere_timestamp, views: views, + author_verified: false, #TODO dummy for components/item.ecr }) LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") @@ -255,6 +257,7 @@ def fetch_channel(ucid, pull_all_videos : Bool) live_now: video.live_now, premiere_timestamp: video.premiere_timestamp, views: video.views, + author_verified: false, #TODO dummy for components/item.ecr }) } videos.each do |video| diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 6febbe45..7d52b918 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -144,8 +144,8 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b content_html = node_comment["contentText"]?.try { |t| parse_content(t) } || "" author = node_comment["authorText"]?.try &.["simpleText"]? || "" - verified = node_comment["authorCommentBadge"]? != nil - json.field "verified", verified + verified = (node_comment["authorCommentBadge"]? != nil) + json.field "verified", (verified || false) json.field "author", author json.field "authorThumbnails" do json.array do @@ -329,7 +329,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end author_name = HTML.escape(child["author"].as_s) - if child["verified"].as_bool + if child["verified"]?.try &.as_bool author_name += "" end html << <<-END_HTML diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index bfbc237c..186bca25 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -12,6 +12,7 @@ struct SearchVideo property live_now : Bool property premium : Bool property premiere_timestamp : Time? + property author_verified : Bool def to_xml(auto_generated, query_params, xml : XML::Builder) query_params["v"] = self.id @@ -129,6 +130,7 @@ struct SearchPlaylist property video_count : Int32 property videos : Array(SearchPlaylistVideo) property thumbnail : String? + property author_verified : Bool def to_json(locale : String?, json : JSON::Builder) json.object do @@ -140,7 +142,7 @@ struct SearchPlaylist json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" - + json.field "authorVerified", self.author_verified json.field "videoCount", self.video_count json.field "videos" do json.array do @@ -182,6 +184,7 @@ struct SearchChannel property video_count : Int32 property description_html : String property auto_generated : Bool + property author_verified : Bool def to_json(locale : String?, json : JSON::Builder) json.object do @@ -189,7 +192,7 @@ struct SearchChannel json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" - + json.field "authorVerified", self.author_verified json.field "authorThumbnails" do json.array do qualities = {32, 48, 76, 100, 176, 512} diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 3f342b92..b578e3d9 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -8,6 +8,10 @@ struct MixVideo property length_seconds : Int32 property index : Int32 property rdid : String + + def author_verified + false #TODO dummy + end end struct Mix diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 88888a65..a17766e3 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -234,6 +234,10 @@ struct InvidiousPlaylist 0_i64 end + def author_verified + false # TODO dummy for components/item.ecr + end + def description_html HTML.escape(self.description) end @@ -252,6 +256,7 @@ def create_playlist(title, privacy, user) updated: Time.utc, privacy: privacy, index: [] of Int64, + author_verified: false, # TODO dummy for components/item.ecr }) Invidious::Database::Playlists.insert(playlist) @@ -270,6 +275,7 @@ def subscribe_playlist(user, playlist) updated: playlist.updated, privacy: PlaylistPrivacy::Private, index: [] of Int64, + author_verified: false, # TODO dummy for components/item.ecr }) Invidious::Database::Playlists.insert(playlist) diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index f7f7b426..6d1f098f 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -182,6 +182,7 @@ module Invidious::Routes::Feeds paid: false, premium: false, premiere_timestamp: nil, + author_verified: false, #TODO real value }) end @@ -414,6 +415,7 @@ module Invidious::Routes::Feeds live_now: video.live_now, premiere_timestamp: video.premiere_timestamp, views: video.views, + author_verified: false, #TODO dummy for components/item.ecr }) was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 69468b5e..c52cbe58 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1047,7 +1047,9 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url") - params["authorVerified"] = JSON::Any.new(author_info.try &.["badges"]? != nil) + author_verified_badge = author_info.try &.["badges"]? + + params["authorVerified"] = JSON::Any.new((author_verified_badge && author_verified_badge.size > 0) || false) params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 5a93d802..db003146 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -8,7 +8,7 @@ "/> <% end %> -

    <%= HTML.escape(item.author) %>

    +

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %><% end %>

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -30,7 +30,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %>

    +

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %><% end %>

    <% when MixVideo %> @@ -45,7 +45,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %>

    +

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %><% end %>

    <% when PlaylistVideo %> @@ -142,7 +142,7 @@
    <% endpoint_params = "?v=#{item.id}" %> diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 41d95962..28e920fa 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -102,7 +102,11 @@ private module Parsers premium = false premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } + author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| + badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") + end + author_verified = (author_verified_badge && author_verified_badge.size > 0) item_contents["badges"]?.try &.as_a.each do |badge| b = badge["metadataBadgeRenderer"] case b["label"].as_s @@ -129,6 +133,7 @@ private module Parsers live_now: live_now, premium: premium, premiere_timestamp: premiere_timestamp, + author_verified: author_verified || false, }) end @@ -156,7 +161,11 @@ private module Parsers private def self.parse(item_contents, author_fallback) author = extract_text(item_contents["title"]) || author_fallback.name author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id + author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| + badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") + end + author_verified = (author_verified_badge && author_verified_badge.size > 0) author_thumbnail = HelperExtractors.get_thumbnails(item_contents) # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. # Always simpleText @@ -179,6 +188,7 @@ private module Parsers video_count: video_count, description_html: description_html, auto_generated: auto_generated, + author_verified: author_verified || false, }) end @@ -206,18 +216,23 @@ private module Parsers private def self.parse(item_contents, author_fallback) title = extract_text(item_contents["title"]) || "" plid = item_contents["playlistId"]?.try &.as_s || "" + author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| + badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") + end + author_verified = (author_verified_badge && author_verified_badge.size > 0) video_count = HelperExtractors.get_video_count(item_contents) playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents) SearchPlaylist.new({ - title: title, - id: plid, - author: author_fallback.name, - ucid: author_fallback.id, - video_count: video_count, - videos: [] of SearchPlaylistVideo, - thumbnail: playlist_thumbnail, + title: title, + id: plid, + author: author_fallback.name, + ucid: author_fallback.id, + video_count: video_count, + videos: [] of SearchPlaylistVideo, + thumbnail: playlist_thumbnail, + author_verified: author_verified || false, }) end @@ -251,7 +266,11 @@ private module Parsers author_info = item_contents.dig?("shortBylineText", "runs", 0) author = author_info.try &.["text"].as_s || author_fallback.name author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id + author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| + badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") + end + author_verified = (author_verified_badge && author_verified_badge.size > 0) videos = item_contents["videos"]?.try &.as_a.map do |v| v = v["childVideoRenderer"] v_title = v.dig?("title", "simpleText").try &.as_s || "" @@ -267,13 +286,14 @@ private module Parsers # TODO: item_contents["publishedTimeText"]? SearchPlaylist.new({ - title: title, - id: plid, - author: author, - ucid: author_id, - video_count: video_count, - videos: videos, - thumbnail: playlist_thumbnail, + title: title, + id: plid, + author: author, + ucid: author_id, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail, + author_verified: author_verified || false, }) end -- cgit v1.2.3 From 1668e4187ee1c1432e9af7b71bb266be60ca72e1 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 7 Feb 2022 00:37:47 +0100 Subject: Related channel may contain a continuation entry --- src/invidious/channels/about.cr | 31 ++++++++++++++++++++++--------- src/invidious/routes/api/v1/channels.cr | 8 ++++++-- 2 files changed, 28 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 0f3928f5..4f82a0f1 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -140,19 +140,32 @@ def fetch_related_channels(about_channel : AboutChannel) : Array(AboutRelatedCha return [] of AboutRelatedChannel if tab.nil? - items = tab.dig?("tabRenderer", "content", "sectionListRenderer", "contents", 0, "itemSectionRenderer", "contents", 0, "gridRenderer", "items").try(&.as_a?) || [] of JSON::Any - - items.map do |item| - related_id = item.dig("gridChannelRenderer", "channelId").as_s - related_title = item.dig("gridChannelRenderer", "title", "simpleText").as_s - related_author_url = item.dig("gridChannelRenderer", "navigationEndpoint", "browseEndpoint", "canonicalBaseUrl").as_s - related_author_thumbnail = item.dig("gridChannelRenderer", "thumbnail", "thumbnails", -1, "url").as_s - - AboutRelatedChannel.new( + items = tab.dig?( + "tabRenderer", "content", + "sectionListRenderer", "contents", 0, + "itemSectionRenderer", "contents", 0, + "gridRenderer", "items" + ).try &.as_a? + + related = [] of AboutRelatedChannel + return related if (items.nil? || items.empty?) + + items.each do |item| + renderer = item["gridChannelRenderer"]? + next if !renderer + + related_id = renderer.dig("channelId").as_s + related_title = renderer.dig("title", "simpleText").as_s + related_author_url = renderer.dig("navigationEndpoint", "browseEndpoint", "canonicalBaseUrl").as_s + related_author_thumbnail = HelperExtractors.get_thumbnails(renderer) + + related << AboutRelatedChannel.new( ucid: related_id, author: related_title, author_url: related_author_url, author_thumbnail: related_author_thumbnail, ) end + + return related end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 3e55b412..83c6db04 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -96,7 +96,10 @@ module Invidious::Routes::API::V1::Channels json.field "relatedChannels" do json.array do - fetch_related_channels(channel).each do |related_channel| + # Fetch related channels + related_channels = fetch_related_channels(channel) + + related_channels.each do |related_channel| json.object do json.field "author", related_channel.author json.field "authorId", related_channel.ucid @@ -118,7 +121,8 @@ module Invidious::Routes::API::V1::Channels end end end - end + end # relatedChannels + end end end -- cgit v1.2.3 From 698a6f38863c399b50cda27b5b509be2980e8a21 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 7 Feb 2022 00:52:18 +0100 Subject: API: handle related channels parsing exceptions --- src/invidious/routes/api/v1/channels.cr | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 83c6db04..c4d6643a 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -97,7 +97,11 @@ module Invidious::Routes::API::V1::Channels json.field "relatedChannels" do json.array do # Fetch related channels - related_channels = fetch_related_channels(channel) + begin + related_channels = fetch_related_channels(channel) + rescue ex + related_channels = [] of AboutRelatedChannel + end related_channels.each do |related_channel| json.object do -- cgit v1.2.3 From 9205ccc12417bd6797d3900e19f440cf3674d427 Mon Sep 17 00:00:00 2001 From: jonas-w Date: Mon, 7 Feb 2022 02:00:43 +0100 Subject: Removed dummy values and added checks for items.ecr --- src/invidious/channels/channels.cr | 3 --- src/invidious/mixes.cr | 5 +---- src/invidious/playlists.cr | 9 ++------- src/invidious/routes/feeds.cr | 5 ++--- src/invidious/views/components/item.ecr | 6 +++--- 5 files changed, 8 insertions(+), 20 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 9e701043..e0459cc3 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -21,7 +21,6 @@ struct ChannelVideo property live_now : Bool = false property premiere_timestamp : Time? = nil property views : Int64? = nil - property author_verified : Bool #TODO currently a dummy def to_json(locale, json : JSON::Builder) json.object do @@ -219,7 +218,6 @@ def fetch_channel(ucid, pull_all_videos : Bool) live_now: live_now, premiere_timestamp: premiere_timestamp, views: views, - author_verified: false, #TODO dummy for components/item.ecr }) LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updating or inserting video") @@ -257,7 +255,6 @@ def fetch_channel(ucid, pull_all_videos : Bool) live_now: video.live_now, premiere_timestamp: video.premiere_timestamp, views: video.views, - author_verified: false, #TODO dummy for components/item.ecr }) } videos.each do |video| diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index b578e3d9..b3edea27 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -8,10 +8,7 @@ struct MixVideo property length_seconds : Int32 property index : Int32 property rdid : String - - def author_verified - false #TODO dummy - end + end struct Mix diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index a17766e3..8383b185 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -234,9 +234,6 @@ struct InvidiousPlaylist 0_i64 end - def author_verified - false # TODO dummy for components/item.ecr - end def description_html HTML.escape(self.description) @@ -255,8 +252,7 @@ def create_playlist(title, privacy, user) created: Time.utc, updated: Time.utc, privacy: privacy, - index: [] of Int64, - author_verified: false, # TODO dummy for components/item.ecr + index: [] of Int64 }) Invidious::Database::Playlists.insert(playlist) @@ -274,8 +270,7 @@ def subscribe_playlist(user, playlist) created: Time.utc, updated: playlist.updated, privacy: PlaylistPrivacy::Private, - index: [] of Int64, - author_verified: false, # TODO dummy for components/item.ecr + index: [] of Int64 }) Invidious::Database::Playlists.insert(playlist) diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 6d1f098f..c26e6da7 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -156,7 +156,7 @@ module Invidious::Routes::Feeds response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") rss = XML.parse_html(response.body) - + print(response) videos = rss.xpath_nodes("//feed/entry").map do |entry| video_id = entry.xpath_node("videoid").not_nil!.content title = entry.xpath_node("title").not_nil!.content @@ -182,7 +182,7 @@ module Invidious::Routes::Feeds paid: false, premium: false, premiere_timestamp: nil, - author_verified: false, #TODO real value + author_verified: false, # ¯\_(ツ)_/¯ }) end @@ -415,7 +415,6 @@ module Invidious::Routes::Feeds live_now: video.live_now, premiere_timestamp: video.premiere_timestamp, views: video.views, - author_verified: false, #TODO dummy for components/item.ecr }) was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index db003146..bc59233f 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -30,7 +30,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %><% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %><% end %>

    <% when MixVideo %> @@ -45,7 +45,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %><% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.is_a?(MixVideo) && !item.author_verified.nil? && item.author_verified %><% end %>

    <% when PlaylistVideo %> @@ -142,7 +142,7 @@
    <% endpoint_params = "?v=#{item.id}" %> -- cgit v1.2.3 From fe55141a7b5ba3372cc0f850fc388ef115d94a0d Mon Sep 17 00:00:00 2001 From: jonas-w Date: Mon, 7 Feb 2022 02:04:50 +0100 Subject: Crystal format --- src/invidious/mixes.cr | 1 - src/invidious/playlists.cr | 5 ++--- src/invidious/routes/feeds.cr | 4 ++-- 3 files changed, 4 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index b3edea27..3f342b92 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -8,7 +8,6 @@ struct MixVideo property length_seconds : Int32 property index : Int32 property rdid : String - end struct Mix diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 8383b185..88888a65 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -234,7 +234,6 @@ struct InvidiousPlaylist 0_i64 end - def description_html HTML.escape(self.description) end @@ -252,7 +251,7 @@ def create_playlist(title, privacy, user) created: Time.utc, updated: Time.utc, privacy: privacy, - index: [] of Int64 + index: [] of Int64, }) Invidious::Database::Playlists.insert(playlist) @@ -270,7 +269,7 @@ def subscribe_playlist(user, playlist) created: Time.utc, updated: playlist.updated, privacy: PlaylistPrivacy::Private, - index: [] of Int64 + index: [] of Int64, }) Invidious::Database::Playlists.insert(playlist) diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index c26e6da7..b5b58399 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -156,7 +156,7 @@ module Invidious::Routes::Feeds response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") rss = XML.parse_html(response.body) - print(response) + videos = rss.xpath_nodes("//feed/entry").map do |entry| video_id = entry.xpath_node("videoid").not_nil!.content title = entry.xpath_node("title").not_nil!.content @@ -182,7 +182,7 @@ module Invidious::Routes::Feeds paid: false, premium: false, premiere_timestamp: nil, - author_verified: false, # ¯\_(ツ)_/¯ + author_verified: false, # ¯\_(ツ)_/¯ }) end -- cgit v1.2.3 From f8b29674b2f09c46b74f435a485bebd04c8ce73d Mon Sep 17 00:00:00 2001 From: jonas-w Date: Mon, 7 Feb 2022 02:25:34 +0100 Subject: Gave them marks some space and added nil checks --- src/invidious/comments.cr | 2 +- src/invidious/views/channel.ecr | 2 +- src/invidious/views/community.ecr | 2 +- src/invidious/views/components/item.ecr | 8 ++++---- src/invidious/views/playlists.ecr | 2 +- src/invidious/views/watch.ecr | 6 +++--- 6 files changed, 11 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 7d52b918..c8533c30 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -330,7 +330,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) author_name = HTML.escape(child["author"].as_s) if child["verified"]?.try &.as_bool - author_name += "" + author_name += " " end html << <<-END_HTML
    diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index a32a2eed..197c636b 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -20,7 +20,7 @@
    - <%= author %><% if channel.verified %><% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 7b002f04..10ac5f04 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -19,7 +19,7 @@
    - <%= author %><% if channel.verified %><% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index bc59233f..8b8df07f 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -8,7 +8,7 @@ "/> <% end %> -

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %><% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -30,7 +30,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %><% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

    <% when MixVideo %> @@ -45,7 +45,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %><% if !item.is_a?(MixVideo) && !item.author_verified.nil? && item.author_verified %><% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.is_a?(MixVideo) && !item.author_verified.nil? && item.author_verified %> <% end %>

    <% when PlaylistVideo %> @@ -142,7 +142,7 @@
    <% endpoint_params = "?v=#{item.id}" %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 63badf76..94d7a753 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -19,7 +19,7 @@
    - <%= author %><% if channel.verified %><% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 496ceddc..4593affc 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -240,7 +240,7 @@ we're going to need to do it here in order to allow for translations. <% if !video.author_thumbnail.empty? %> <% end %> - <%= author %><% if video.author_verified %><% end %> + <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %>
    @@ -314,9 +314,9 @@ we're going to need to do it here in order to allow for translations.
    <% if rv["ucid"]? %> - "><%= rv["author"]? %><% if rv["author_verified"].== "true" %><% end %> + "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> <% else %> - <%= rv["author"]? %><% if rv["author_verified"].== "true" %><% end %> + <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> <% end %>
    -- cgit v1.2.3 From 7e4690e43c9d037134cc0700b3ced7b46de94dcb Mon Sep 17 00:00:00 2001 From: Dimitris Apostolou Date: Mon, 7 Feb 2022 14:57:14 +0200 Subject: Fix typos --- assets/js/player.js | 6 +++--- assets/js/themes.js | 2 +- config/config.example.yml | 22 +++++++++++----------- src/invidious/config.cr | 4 ++-- src/invidious/helpers/i18n.cr | 2 +- src/invidious/helpers/tokens.cr | 2 +- src/invidious/helpers/utils.cr | 12 ++++++------ src/invidious/playlists.cr | 2 +- src/invidious/routes/preferences.cr | 2 +- src/invidious/yt_backend/extractors.cr | 2 +- src/invidious/yt_backend/youtube_api.cr | 14 +++++++------- 11 files changed, 35 insertions(+), 35 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index 66d1682f..a5ea08ec 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -86,7 +86,7 @@ if (location.pathname.startsWith('/embed/')) { }); } -// Detect mobile users and initalize mobileUi for better UX +// Detect mobile users and initialize mobileUi for better UX // Detection code taken from https://stackoverflow.com/a/20293441 function isMobile() { @@ -119,7 +119,7 @@ if (isMobile()) { operations_bar_element.className += " mobile-operations-bar" player.addChild(operations_bar) - // Playback menu doesn't work when its initalized outside of the primary control bar + // Playback menu doesn't work when it's initialized outside of the primary control bar playback_element = document.getElementsByClassName("vjs-playback-rate")[0] operations_bar_element.append(playback_element) @@ -138,7 +138,7 @@ if (isMobile()) { player.on('error', function (event) { if (player.error().code === 2 || player.error().code === 4) { setTimeout(function (event) { - console.log('An error occured in the player, reloading...'); + console.log('An error occurred in the player, reloading...'); var currentTime = player.currentTime(); var playbackRate = player.playbackRate(); diff --git a/assets/js/themes.js b/assets/js/themes.js index 470f10bf..0214a7f0 100644 --- a/assets/js/themes.js +++ b/assets/js/themes.js @@ -77,7 +77,7 @@ function update_mode (mode) { // If preference for dark mode indicated set_mode(true); } - else if (mode === 'false' /* for backwards compaibility */ || mode === 'light') { + else if (mode === 'false' /* for backwards compatibility */ || mode === 'light') { // If preference for light mode indicated set_mode(false); } diff --git a/config/config.example.yml b/config/config.example.yml index d1c1f300..59cb486b 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -163,7 +163,7 @@ https_only: false #use_quic: false ## -## Additionnal cookies to be sent when requesting the youtube API. +## Additional cookies to be sent when requesting the youtube API. ## ## Accepted values: a string in the format "name1=value1; name2=value2..." ## Default: @@ -188,7 +188,7 @@ https_only: false ## ## Path to log file. Can be absolute or relative to the invidious -## binary. This is overriden if "-o OUTPUT" or "--output=OUTPUT" +## binary. This is overridden if "-o OUTPUT" or "--output=OUTPUT" ## are passed on the command line. ## ## Accepted values: a filesystem path or 'STDOUT' @@ -197,7 +197,7 @@ https_only: false #output: STDOUT ## -## Logging Verbosity. This is overriden if "-l LEVEL" or +## Logging Verbosity. This is overridden if "-l LEVEL" or ## "--log-level=LEVEL" are passed on the command line. ## ## Accepted values: All, Trace, Debug, Info, Warn, Error, Fatal, Off @@ -306,7 +306,7 @@ https_only: false ## ## Notes: ## - Setting this to 0 will disable the channel videos crawl job. -## - This setting is overriden if "-c THREADS" or +## - This setting is overridden if "-c THREADS" or ## "--channel-threads=THREADS" are passed on the command line. ## ## Accepted values: a positive integer @@ -328,7 +328,7 @@ full_refresh: false ## ## Notes: ## - Setting this to 0 will disable the channel videos crawl job. -## - This setting is overriden if "-f THREADS" or +## - This setting is overridden if "-f THREADS" or ## "--feed-threads=THREADS" are passed on the command line. ## ## Accepted values: a positive integer @@ -371,7 +371,7 @@ feed_threads: 1 # ----------------------------- -# Miscellanous +# Miscellaneous # ----------------------------- ## @@ -433,7 +433,7 @@ feed_threads: 1 #cache_annotations: false ## -## Source code URL. If your instance is running a modfied source +## Source code URL. If your instance is running a modified source ## code, you MUST publish it somewhere and set this option. ## ## Accepted values: a string @@ -520,9 +520,9 @@ default_user_preferences: #region: US ## - ## Top 3 prefered languages for video captions. + ## Top 3 preferred languages for video captions. ## - ## Note: overridin the default (no preferred + ## Note: overriding the default (no preferred ## caption language) is not recommended, in order ## to not penalize people using other languages. ## @@ -594,7 +594,7 @@ default_user_preferences: #feed_menu: ["Popular", "Trending", "Subscriptions", "Playlists"] ## - ## Default feed to diplay on the home page. + ## Default feed to display on the home page. ## ## Note: setting this option to "Popular" has no ## effect when 'popular_enabled' is set to false. @@ -812,7 +812,7 @@ default_user_preferences: # ----------------------------- - # Miscellanous + # Miscellaneous # ----------------------------- ## diff --git a/src/invidious/config.cr b/src/invidious/config.cr index c4a8bf83..72e145da 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -91,8 +91,8 @@ class Config @[YAML::Field(converter: Preferences::FamilyConverter)] property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) - property port : Int32 = 3000 # Port to listen for connections (overrided by command line argument) - property host_binding : String = "0.0.0.0" # Host to bind (overrided by command line argument) + property port : Int32 = 3000 # Port to listen for connections (overridden by command line argument) + property host_binding : String = "0.0.0.0" # Host to bind (overridden by command line argument) property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) property use_quic : Bool = false # Use quic transport for youtube api diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 3cf9ad1c..6571dbe6 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -135,7 +135,7 @@ def translate_count(locale : String, key : String, count : Int, format = NumberF # Try #2: Fallback to english translation = translate_count("en-US", key, count) else - # Return key if we're already in english, as the tranlation is missing + # Return key if we're already in english, as the translation is missing LOGGER.warn("i18n: Missing translation key \"#{key}\"") return key end diff --git a/src/invidious/helpers/tokens.cr b/src/invidious/helpers/tokens.cr index 9b664646..a44988cd 100644 --- a/src/invidious/helpers/tokens.cr +++ b/src/invidious/helpers/tokens.cr @@ -44,7 +44,7 @@ def sign_token(key, hash) # TODO: figure out which "key" variable is used # Ameba reports a warning for "Lint/ShadowingOuterLocalVar" on this - # variable, but its preferrable to not touch that (works fine atm). + # variable, but it's preferable to not touch that (works fine atm). hash.each do |key, value| next if key == "signature" diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 3ab9a0fc..a58a21b1 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -161,11 +161,11 @@ def short_text_to_number(short_text : String) : Int32 end def number_to_short_text(number) - seperated = number_with_separator(number).gsub(",", ".").split("") - text = seperated.first(2).join + separated = number_with_separator(number).gsub(",", ".").split("") + text = separated.first(2).join - if seperated[2]? && seperated[2] != "." - text += seperated[2] + if separated[2]? && separated[2] != "." + text += separated[2] end text = text.rchop(".0") @@ -323,8 +323,8 @@ def fetch_random_instance instance_list.each do |data| # TODO Check if current URL is onion instance and use .onion types if so. if data[1]["type"] == "https" - # Instances can have statisitics disabled, which is an requirement of version validation. - # as_nil? doesn't exist. Thus we'll have to handle the error rasied if as_nil fails. + # Instances can have statistics disabled, which is an requirement of version validation. + # as_nil? doesn't exist. Thus we'll have to handle the error raised if as_nil fails. begin data[1]["stats"].as_nil next diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 88888a65..aefa34cc 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -401,7 +401,7 @@ def fetch_playlist(plid : String) end def get_playlist_videos(playlist : InvidiousPlaylist | Playlist, offset : Int32, video_id = nil) - # Show empy playlist if requested page is out of range + # Show empty playlist if requested page is out of range # (e.g, when a new playlist has been created, offset will be negative) if offset >= playlist.video_count || offset < 0 return [] of PlaylistVideo diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 9c740cf2..930c588b 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -136,7 +136,7 @@ module Invidious::Routes::PreferencesRoute notifications_only ||= "off" notifications_only = notifications_only == "on" - # Convert to JSON and back again to take advantage of converters used for compatability + # Convert to JSON and back again to take advantage of converters used for compatibility preferences = Preferences.from_json({ annotations: annotations, annotations_subscribed: annotations_subscribed, diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 41d95962..ce39bc28 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -568,7 +568,7 @@ def extract_item(item : JSON::Any, author_fallback : String? = "", # Cycles through all of the item parsers and attempt to parse the raw YT JSON data. # Each parser automatically validates the data given to see if the data is - # applicable to itself. If not nil is returned and the next parser is attemped. + # applicable to itself. If not nil is returned and the next parser is attempted. ITEM_PARSERS.each do |parser| LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 426c076a..5bbd9213 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -90,7 +90,7 @@ module YoutubeAPI property client_type : ClientType # Region to provide to youtube, e.g to alter search results - # (this is passed as the `gl` parmeter). + # (this is passed as the `gl` parameter). property region : String | Nil # ISO code of country where the proxy is located. @@ -205,7 +205,7 @@ module YoutubeAPI # :ditto: def browse( browse_id : String, - *, # Force the following paramters to be passed by name + *, # Force the following parameters to be passed by name params : String, client_config : ClientConfig | Nil = nil ) @@ -215,7 +215,7 @@ module YoutubeAPI "context" => self.make_context(client_config), } - # Append the additionnal parameters if those were provided + # Append the additional parameters if those were provided # (this is required for channel info, playlist and community, e.g) if params != "" data["params"] = params @@ -292,14 +292,14 @@ module YoutubeAPI # and POST data in order to get a JSON reply. # # The requested data is a video ID (`v=` parameter), with some - # additional paramters, formatted as a base64 string. + # additional parameters, formatted as a base64 string. # # An optional ClientConfig parameter can be passed, too (see # `struct ClientConfig` above for more details). # def player( video_id : String, - *, # Force the following paramters to be passed by name + *, # Force the following parameters to be passed by name params : String, client_config : ClientConfig | Nil = nil ) @@ -309,7 +309,7 @@ module YoutubeAPI "context" => self.make_context(client_config), } - # Append the additionnal parameters if those were provided + # Append the additional parameters if those were provided if params != "" data["params"] = params end @@ -363,7 +363,7 @@ module YoutubeAPI # order to get non-US results. # # The requested data is a search string, with some additional - # paramters, formatted as a base64 string. + # parameters, formatted as a base64 string. # # An optional ClientConfig parameter can be passed, too (see # `struct ClientConfig` above for more details). -- cgit v1.2.3 From 7ace3fc989d5b24104af92537dc3a67cf9f608c3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 4 Feb 2022 04:46:38 +0100 Subject: Move remaining user-related routes out of main file --- src/invidious.cr | 333 ++----------------------------------- src/invidious/routes/account.cr | 358 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 372 insertions(+), 319 deletions(-) create mode 100644 src/invidious/routes/account.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index f4cae7ea..6f4f575b 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -364,16 +364,30 @@ end Invidious::Routing.get "/results", Invidious::Routes::Search, :results Invidious::Routing.get "/search", Invidious::Routes::Search, :search + # User login/out Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page Invidious::Routing.post "/login", Invidious::Routes::Login, :login Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout + # User preferences Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control + # User account management + Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password + Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password + Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete + Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete + Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history + Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history + Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token + Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token + Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager + Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax + # Feeds Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists @@ -412,325 +426,6 @@ define_v1_api_routes() define_api_manifest_routes() define_video_playback_routes() -get "/change_password" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY) - - templated "change_password" -end - -post "/change_password" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - # We don't store passwords for Google accounts - if !user.password - next error_template(400, "Cannot change password for Google accounts") - end - - begin - validate_request(token, sid, env.request, HMAC_KEY, locale) - rescue ex - next error_template(400, ex) - end - - password = env.params.body["password"]? - if !password - next error_template(401, "Password is a required field") - end - - new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v } - - if new_passwords.size <= 1 || new_passwords.uniq.size != 1 - next error_template(400, "New passwords must match") - end - - new_password = new_passwords.uniq[0] - if new_password.empty? - next error_template(401, "Password cannot be empty") - end - - if new_password.bytesize > 55 - next error_template(400, "Password cannot be longer than 55 characters") - end - - if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) - next error_template(401, "Incorrect password") - end - - new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10) - Invidious::Database::Users.update_password(user, new_password.to_s) - - env.redirect referer -end - -get "/delete_account" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY) - - templated "delete_account" -end - -post "/delete_account" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - 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, locale) - rescue ex - next error_template(400, ex) - end - - view_name = "subscriptions_#{sha256(user.email)}" - Invidious::Database::Users.delete(user) - Invidious::Database::SessionIDs.delete(email: user.email) - PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}") - - env.request.cookies.each do |cookie| - cookie.expires = Time.utc(1990, 1, 1) - env.response.cookies << cookie - end - - env.redirect referer -end - -get "/clear_watch_history" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY) - - templated "clear_watch_history" -end - -post "/clear_watch_history" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - 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, locale) - rescue ex - next error_template(400, ex) - end - - Invidious::Database::Users.clear_watch_history(user) - env.redirect referer -end - -get "/authorize_token" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = user.as(User) - sid = sid.as(String) - csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY) - - scopes = env.params.query["scopes"]?.try &.split(",") - scopes ||= [] of String - - callback_url = env.params.query["callback_url"]? - if callback_url - callback_url = URI.parse(callback_url) - end - - expire = env.params.query["expire"]?.try &.to_i? - - templated "authorize_token" -end - -post "/authorize_token" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env) - - if !user - next env.redirect referer - end - - user = env.get("user").as(User) - sid = sid.as(String) - token = env.params.body["csrf_token"]? - - begin - validate_request(token, sid, env.request, HMAC_KEY, locale) - rescue ex - next error_template(400, ex) - end - - scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } - callback_url = env.params.body["callbackUrl"]? - expire = env.params.body["expire"]?.try &.to_i? - - access_token = generate_token(user.email, scopes, expire, HMAC_KEY) - - if callback_url - access_token = URI.encode_www_form(access_token) - url = URI.parse(callback_url) - - if url.query - query = HTTP::Params.parse(url.query.not_nil!) - else - query = HTTP::Params.new - end - - query["token"] = access_token - url.query = query.to_s - - env.redirect url.to_s - else - csrf_token = "" - env.set "access_token", access_token - templated "authorize_token" - end -end - -get "/token_manager" do |env| - locale = env.get("preferences").as(Preferences).locale - - user = env.get? "user" - sid = env.get? "sid" - referer = get_referer(env, "/subscription_manager") - - if !user - next env.redirect referer - end - - user = user.as(User) - tokens = Invidious::Database::SessionIDs.select_all(user.email) - - templated "token_manager" -end - -post "/token_ajax" do |env| - locale = 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 - next error_json(403, "No such user") - 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, locale) - rescue ex - if redirect - next error_template(400, ex) - else - next error_json(400, ex) - end - end - - if env.params.query["action_revoke_token"]? - action = "action_revoke_token" - else - next env.redirect referer - end - - session = env.params.query["session"]? - session ||= "" - - case action - when .starts_with? "action_revoke_token" - Invidious::Database::SessionIDs.delete(sid: session, email: user.email) - else - next error_json(400, "Unsupported action #{action}") - end - - if redirect - env.redirect referer - else - env.response.content_type = "application/json" - "{}" - end -end - # Channels {"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route| diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr new file mode 100644 index 00000000..2be0de37 --- /dev/null +++ b/src/invidious/routes/account.cr @@ -0,0 +1,358 @@ +{% skip_file if flag?(:api_only) %} + +module Invidious::Routes::Account + extend self + + # ------------------- + # Password update + # ------------------- + + # Show the password change interface (GET request) + def get_change_password(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY) + + templated "change_password" + end + + # Handle the password change (POST request) + def post_change_password(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + # We don't store passwords for Google accounts + if !user.password + return error_template(400, "Cannot change password for Google accounts") + end + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + return error_template(400, ex) + end + + password = env.params.body["password"]? + if !password + return error_template(401, "Password is a required field") + end + + new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v } + + if new_passwords.size <= 1 || new_passwords.uniq.size != 1 + return error_template(400, "New passwords must match") + end + + new_password = new_passwords.uniq[0] + if new_password.empty? + return error_template(401, "Password cannot be empty") + end + + if new_password.bytesize > 55 + return error_template(400, "Password cannot be longer than 55 characters") + end + + if !Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) + return error_template(401, "Incorrect password") + end + + new_password = Crypto::Bcrypt::Password.create(new_password, cost: 10) + Invidious::Database::Users.update_password(user, new_password.to_s) + + env.redirect referer + end + + # ------------------- + # Account deletion + # ------------------- + + # Show the account deletion confirmation prompt (GET request) + def get_delete(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY) + + templated "delete_account" + end + + # Handle the account deletion (POST request) + def post_delete(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return 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, locale) + rescue ex + return error_template(400, ex) + end + + view_name = "subscriptions_#{sha256(user.email)}" + Invidious::Database::Users.delete(user) + Invidious::Database::SessionIDs.delete(email: user.email) + PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}") + + env.request.cookies.each do |cookie| + cookie.expires = Time.utc(1990, 1, 1) + env.response.cookies << cookie + end + + env.redirect referer + end + + # ------------------- + # Clear history + # ------------------- + + # Show the watch history deletion confirmation prompt (GET request) + def get_clear_history(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY) + + templated "clear_watch_history" + end + + # Handle the watch history clearing (POST request) + def post_clear_history(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return 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, locale) + rescue ex + return error_template(400, ex) + end + + Invidious::Database::Users.clear_watch_history(user) + env.redirect referer + end + + # ------------------- + # Authorize tokens + # ------------------- + + # Show the "authorize token?" confirmation prompt (GET request) + def get_authorize_token(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = user.as(User) + sid = sid.as(String) + csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY) + + scopes = env.params.query["scopes"]?.try &.split(",") + scopes ||= [] of String + + callback_url = env.params.query["callback_url"]? + if callback_url + callback_url = URI.parse(callback_url) + end + + expire = env.params.query["expire"]?.try &.to_i? + + templated "authorize_token" + end + + # Handle token authorization (POST request) + def post_authorize_token(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env) + + if !user + return env.redirect referer + end + + user = env.get("user").as(User) + sid = sid.as(String) + token = env.params.body["csrf_token"]? + + begin + validate_request(token, sid, env.request, HMAC_KEY, locale) + rescue ex + return error_template(400, ex) + end + + scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } + callback_url = env.params.body["callbackUrl"]? + expire = env.params.body["expire"]?.try &.to_i? + + access_token = generate_token(user.email, scopes, expire, HMAC_KEY) + + if callback_url + access_token = URI.encode_www_form(access_token) + url = URI.parse(callback_url) + + if url.query + query = HTTP::Params.parse(url.query.not_nil!) + else + query = HTTP::Params.new + end + + query["token"] = access_token + url.query = query.to_s + + env.redirect url.to_s + else + csrf_token = "" + env.set "access_token", access_token + templated "authorize_token" + end + end + + # ------------------- + # Manage tokens + # ------------------- + + # Show the token manager page (GET request) + def token_manager(env) + locale = env.get("preferences").as(Preferences).locale + + user = env.get? "user" + sid = env.get? "sid" + referer = get_referer(env, "/subscription_manager") + + if !user + return env.redirect referer + end + + user = user.as(User) + tokens = Invidious::Database::SessionIDs.select_all(user.email) + + templated "token_manager" + end + + # ------------------- + # AJAX for tokens + # ------------------- + + # Handle internal (non-API) token actions (POST request) + def token_ajax(env) + locale = 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 + return env.redirect referer + else + return error_json(403, "No such user") + 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, locale) + rescue ex + if redirect + return error_template(400, ex) + else + return error_json(400, ex) + end + end + + if env.params.query["action_revoke_token"]? + action = "action_revoke_token" + else + return env.redirect referer + end + + session = env.params.query["session"]? + session ||= "" + + case action + when .starts_with? "action_revoke_token" + Invidious::Database::SessionIDs.delete(sid: session, email: user.email) + else + return error_json(400, "Unsupported action #{action}") + end + + if redirect + return env.redirect referer + else + env.response.content_type = "application/json" + return "{}" + end + end +end -- cgit v1.2.3 From fb3615502258c71249c6d77aebe684234756b416 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 4 Feb 2022 04:55:50 +0100 Subject: Move user routes definitions to a macro in routing.cr --- src/invidious.cr | 25 ++----------------------- src/invidious/routing.cr | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 23 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 6f4f575b..fc498dbf 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -364,29 +364,8 @@ end Invidious::Routing.get "/results", Invidious::Routes::Search, :results Invidious::Routing.get "/search", Invidious::Routes::Search, :search - # User login/out - Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page - Invidious::Routing.post "/login", Invidious::Routes::Login, :login - Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout - - # User preferences - Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show - Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update - Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme - Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control - Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control - - # User account management - Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password - Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password - Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete - Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete - Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history - Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history - Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token - Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token - Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager - Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax + # User routes + define_user_routes() # Feeds Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 7551f22d..5efe1bd8 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -10,6 +10,32 @@ module Invidious::Routing {% end %} end +macro define_user_routes + # User login/out + Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page + Invidious::Routing.post "/login", Invidious::Routes::Login, :login + Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout + + # User preferences + Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show + Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update + Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme + Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control + Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control + + # User account management + Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password + Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password + Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete + Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete + Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history + Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history + Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token + Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token + Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager + Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax +end + macro define_v1_api_routes {{namespace = Invidious::Routes::API::V1}} # Videos -- cgit v1.2.3 From c04f45d5e36499e6faefd163e92c58fa1abaa7ae Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 4 Feb 2022 04:09:07 +0100 Subject: Move user struct to own file, under Invidious namespace --- src/invidious.cr | 2 +- src/invidious/search.cr | 2 +- src/invidious/user/user.cr | 27 +++++++++++++++++++++++++++ src/invidious/users.cr | 30 ++---------------------------- src/invidious/views/preferences.ecr | 2 +- src/invidious/views/template.ecr | 4 ++-- 6 files changed, 34 insertions(+), 33 deletions(-) create mode 100644 src/invidious/user/user.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index fc498dbf..1e78ef5d 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -548,7 +548,7 @@ add_handler AuthHandler.new add_handler DenyFrame.new add_context_storage_type(Array(String)) add_context_storage_type(Preferences) -add_context_storage_type(User) +add_context_storage_type(Invidious::User) Kemal.config.logger = LOGGER Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.config.host_binding : CONFIG.host_binding diff --git a/src/invidious/search.cr b/src/invidious/search.cr index d8971e79..ae106bf6 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -176,7 +176,7 @@ end def process_search_query(query, page, user, region) if user - user = user.as(User) + user = user.as(Invidious::User) view_name = "subscriptions_#{sha256(user.email)}" end diff --git a/src/invidious/user/user.cr b/src/invidious/user/user.cr new file mode 100644 index 00000000..a6d05fd1 --- /dev/null +++ b/src/invidious/user/user.cr @@ -0,0 +1,27 @@ +require "db" + +struct Invidious::User + include DB::Serializable + + property updated : Time + property notifications : Array(String) + property subscriptions : Array(String) + property email : String + + @[DB::Field(converter: Invidious::User::PreferencesConverter)] + property preferences : Preferences + property password : String? + property token : String + property watched : Array(String) + property feed_needs_update : Bool? + + module PreferencesConverter + def self.from_rs(rs) + begin + Preferences.from_json(rs.read(String)) + rescue ex + Preferences.from_json("{}") + end + end + end +end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index 9810f8a2..b4995e95 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -3,32 +3,6 @@ require "crypto/bcrypt/password" # Materialized views may not be defined using bound parameters (`$1` as used elsewhere) MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" } -struct User - include DB::Serializable - - property updated : Time - property notifications : Array(String) - property subscriptions : Array(String) - property email : String - - @[DB::Field(converter: User::PreferencesConverter)] - property preferences : Preferences - property password : String? - property token : String - property watched : Array(String) - property feed_needs_update : Bool? - - module PreferencesConverter - def self.from_rs(rs) - begin - Preferences.from_json(rs.read(String)) - rescue ex - Preferences.from_json("{}") - end - end - end -end - def get_user(sid, headers, refresh = true) if email = Invidious::Database::SessionIDs.select_email(sid) user = Invidious::Database::Users.select!(email: email) @@ -84,7 +58,7 @@ def fetch_user(sid, headers) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - user = User.new({ + user = Invidious::User.new({ updated: Time.utc, notifications: [] of String, subscriptions: channels, @@ -102,7 +76,7 @@ def create_user(sid, email, password) password = Crypto::Bcrypt::Password.create(password, cost: 10) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - user = User.new({ + user = Invidious::User.new({ updated: Time.utc, notifications: [] of String, subscriptions: [] of String, diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr index 96904259..3606d140 100644 --- a/src/invidious/views/preferences.ecr +++ b/src/invidious/views/preferences.ecr @@ -252,7 +252,7 @@ <% end %> <% end %> - <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(User).email %> + <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %> <%= translate(locale, "preferences_category_admin") %>
    diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 92df1272..bd908dd6 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -52,7 +52,7 @@
    <% if env.get("preferences").as(Preferences).show_nick %>
    - <%= env.get("user").as(User).email %> + <%= env.get("user").as(Invidious::User).email %>
    <% end %>
    -- cgit v1.2.3 From ad4a06fca5d11b57705540818d3eb4e86bb6ac14 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 4 Feb 2022 03:44:10 +0100 Subject: Move user captcha code to its own module --- src/invidious.cr | 15 ++++----- src/invidious/routes/login.cr | 4 +-- src/invidious/user/captcha.cr | 78 +++++++++++++++++++++++++++++++++++++++++++ src/invidious/users.cr | 69 -------------------------------------- 4 files changed, 87 insertions(+), 79 deletions(-) create mode 100644 src/invidious/user/captcha.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 1e78ef5d..06ce3ead 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -38,14 +38,13 @@ require "./invidious/jobs/**" CONFIG = Config.load HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) -PG_DB = DB.open CONFIG.database_url -ARCHIVE_URL = URI.parse("https://archive.org") -LOGIN_URL = URI.parse("https://accounts.google.com") -PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") -REDDIT_URL = URI.parse("https://www.reddit.com") -TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com") -YT_URL = URI.parse("https://www.youtube.com") -HOST_URL = make_host_url(Kemal.config) +PG_DB = DB.open CONFIG.database_url +ARCHIVE_URL = URI.parse("https://archive.org") +LOGIN_URL = URI.parse("https://accounts.google.com") +PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") +REDDIT_URL = URI.parse("https://www.reddit.com") +YT_URL = URI.parse("https://www.youtube.com") +HOST_URL = make_host_url(Kemal.config) CHARS_SAFE = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" TEST_IDS = {"AgbeGFYluEA", "BaW_jenozKc", "a9LDPn-MO4I", "ddFvjfvPnqk", "iqKdEhx-dD4"} diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index f4859e6f..42ac0b1d 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -393,9 +393,9 @@ module Invidious::Routes::Login prompt = "" if captcha_type == "image" - captcha = generate_captcha(HMAC_KEY) + captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) else - captcha = generate_text_captcha(HMAC_KEY) + captcha = Invidious::User::Captcha.generate_text(HMAC_KEY) end return templated "login" diff --git a/src/invidious/user/captcha.cr b/src/invidious/user/captcha.cr new file mode 100644 index 00000000..8a0f67e5 --- /dev/null +++ b/src/invidious/user/captcha.cr @@ -0,0 +1,78 @@ +require "openssl/hmac" + +struct Invidious::User + module Captcha + extend self + + private TEXTCAPTCHA_URL = URI.parse("https://textcaptcha.com") + + def generate_image(key) + second = Random::Secure.rand(12) + second_angle = second * 30 + second = second * 5 + + minute = Random::Secure.rand(12) + minute_angle = minute * 30 + minute = minute * 5 + + hour = Random::Secure.rand(12) + hour_angle = hour * 30 + minute_angle.to_f / 12 + if hour == 0 + hour = 12 + end + + clock_svg = <<-END_SVG + + + + 1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10 + 11 + 12 + + + + + + + END_SVG + + image = "data:image/png;base64," + image += Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true, + input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe + ) do |proc| + Base64.strict_encode(proc.output.gets_to_end) + end + + answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}" + answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer) + + return { + question: image, + tokens: {generate_response(answer, {":login"}, key, use_nonce: true)}, + } + end + + def generate_text(key) + response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body) + response = JSON.parse(response) + + tokens = response["a"].as_a.map do |answer| + generate_response(answer.as_s, {":login"}, key, use_nonce: true) + end + + return { + question: response["q"].as_s, + tokens: tokens, + } + end + end +end diff --git a/src/invidious/users.cr b/src/invidious/users.cr index b4995e95..b763596b 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -91,75 +91,6 @@ def create_user(sid, email, password) return user, sid end -def generate_captcha(key) - second = Random::Secure.rand(12) - second_angle = second * 30 - second = second * 5 - - minute = Random::Secure.rand(12) - minute_angle = minute * 30 - minute = minute * 5 - - hour = Random::Secure.rand(12) - hour_angle = hour * 30 + minute_angle.to_f / 12 - if hour == 0 - hour = 12 - end - - clock_svg = <<-END_SVG - - - - 1 - 2 - 3 - 4 - 5 - 6 - 7 - 8 - 9 - 10 - 11 - 12 - - - - - - - END_SVG - - image = "data:image/png;base64," - image += Process.run(%(rsvg-convert -w 400 -h 400 -b none -f png), shell: true, - input: IO::Memory.new(clock_svg), output: Process::Redirect::Pipe - ) do |proc| - Base64.strict_encode(proc.output.gets_to_end) - end - - answer = "#{hour}:#{minute.to_s.rjust(2, '0')}:#{second.to_s.rjust(2, '0')}" - answer = OpenSSL::HMAC.hexdigest(:sha256, key, answer) - - return { - question: image, - tokens: {generate_response(answer, {":login"}, key, use_nonce: true)}, - } -end - -def generate_text_captcha(key) - response = make_client(TEXTCAPTCHA_URL, &.get("/github.com/iv.org/invidious.json").body) - response = JSON.parse(response) - - tokens = response["a"].as_a.map do |answer| - generate_response(answer.as_s, {":login"}, key, use_nonce: true) - end - - return { - question: response["q"].as_s, - tokens: tokens, - } -end - def subscribe_ajax(channel_id, action, env_headers) headers = HTTP::Headers.new headers["Cookie"] = env_headers["Cookie"] -- cgit v1.2.3 From ef8dc7272beed31189df1568e59b14b805783a62 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 4 Feb 2022 05:19:22 +0100 Subject: Put CSV import function under its own module --- src/invidious/user/imports.cr | 42 ++++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 18 deletions(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 2ae1dcb1..c8580038 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -1,27 +1,33 @@ require "csv" -def parse_subscription_export_csv(csv_content : String) - rows = CSV.new(csv_content, headers: true) - subscriptions = Array(String).new +struct Invidious::User + module Import + extend self - # Counter to limit the amount of imports. - # This is intended to prevent DoS. - row_counter = 0 + # Parse a youtube CSV subscription file + def parse_subscription_export_csv(csv_content : String) + rows = CSV.new(csv_content, headers: true) + subscriptions = Array(String).new - rows.each do |row| - # Limit to 1200 - row_counter += 1 - break if row_counter > 1_200 + # Counter to limit the amount of imports. + # This is intended to prevent DoS. + row_counter = 0 - # Channel ID is the first column in the csv export we can't use the header - # name, because the header name is localized depending on the - # language the user has set on their account - channel_id = row[0].strip + rows.each do |row| + # Limit to 1200 + row_counter += 1 + break if row_counter > 1_200 - next if channel_id.empty? + # Channel ID is the first column in the csv export we can't use the header + # name, because the header name is localized depending on the + # language the user has set on their account + channel_id = row[0].strip - subscriptions << channel_id - end + next if channel_id.empty? + subscriptions << channel_id + end - return subscriptions + return subscriptions + end + end end -- cgit v1.2.3 From 2bbd424fce4ad1d19643b370250c9f8cee8f1e6f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 4 Feb 2022 06:01:07 +0100 Subject: Move import logic to its own module --- spec/invidious/user/imports_spec.cr | 4 +- src/invidious/routes/preferences.cr | 144 +++------------------------ src/invidious/user/imports.cr | 191 +++++++++++++++++++++++++++++++++++- 3 files changed, 203 insertions(+), 136 deletions(-) (limited to 'src') diff --git a/spec/invidious/user/imports_spec.cr b/spec/invidious/user/imports_spec.cr index 5a682ec5..762ce0d8 100644 --- a/spec/invidious/user/imports_spec.cr +++ b/spec/invidious/user/imports_spec.cr @@ -25,9 +25,9 @@ def csv_sample CSV end -Spectator.describe "Invidious::User::Imports" do +Spectator.describe Invidious::User::Import do it "imports CSV" do - subscriptions = parse_subscription_export_csv(csv_sample) + subscriptions = Invidious::User::Import.parse_subscription_export_csv(csv_sample) expect(subscriptions).to be_an(Array(String)) expect(subscriptions.size).to eq(13) diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 930c588b..b574c1c1 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -321,149 +321,27 @@ module Invidious::Routes::PreferencesRoute # TODO: Unify into single import based on content-type case part.name when "import_invidious" - body = JSON.parse(body) - - if body["subscriptions"]? - user.subscriptions += body["subscriptions"].as_a.map(&.as_s) - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions) - - Invidious::Database::Users.update_subscriptions(user) - end - - if body["watch_history"]? - user.watched += body["watch_history"].as_a.map(&.as_s) - user.watched.uniq! - Invidious::Database::Users.update_watch_history(user) - end - - if body["preferences"]? - user.preferences = Preferences.from_json(body["preferences"].to_json) - Invidious::Database::Users.update_preferences(user) - end - - if playlists = body["playlists"]?.try &.as_a? - playlists.each do |item| - title = item["title"]?.try &.as_s?.try &.delete("<>") - description = item["description"]?.try &.as_s?.try &.delete("\r") - privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy } - - next if !title - next if !description - next if !privacy - - playlist = create_playlist(title, privacy, user) - Invidious::Database::Playlists.update_description(playlist.id, description) - - videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| - raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 - - video_id = video_id.try &.as_s? - next if !video_id - - begin - video = get_video(video_id) - rescue ex - next - 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), - }) - - Invidious::Database::PlaylistVideos.insert(playlist_video) - Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) - end - end - end + Invidious::User::Import.from_invidious(user, body) when "import_youtube" filename = part.filename || "" - extension = filename.split(".").last - - if extension == "xml" || type == "application/xml" || type == "text/xml" - subscriptions = XML.parse(body) - user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| - channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] - end - elsif extension == "json" || type == "application/json" - subscriptions = JSON.parse(body) - user.subscriptions += subscriptions.as_a.compact_map do |entry| - entry["snippet"]["resourceId"]["channelId"].as_s - end - elsif extension == "csv" || type == "text/csv" - subscriptions = parse_subscription_export_csv(body) - user.subscriptions += subscriptions - else + success = Invidious::User::Import.from_youtube(user, body, filename, type) + + if !success haltf(env, status_code: 415, response: error_template(415, "Invalid subscription file uploaded") ) end - - user.subscriptions.uniq! - user.subscriptions = get_batch_channels(user.subscriptions) - - Invidious::Database::Users.update_subscriptions(user) when "import_freetube" - user.subscriptions += body.scan(/"channelId":"(?[a-zA-Z0-9_-]{24})"/).map do |md| - md["channel_id"] - end - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions) - - Invidious::Database::Users.update_subscriptions(user) + Invidious::User::Import.from_freetube(user, body) when "import_newpipe_subscriptions" - body = JSON.parse(body) - user.subscriptions += body["subscriptions"].as_a.compact_map do |channel| - if match = channel["url"].as_s.match(/\/channel\/(?UC[a-zA-Z0-9_-]{22})/) - next match["channel"] - elsif match = channel["url"].as_s.match(/\/user\/(?.+)/) - response = YT_POOL.client &.get("/user/#{match["user"]}?disable_polymer=1&hl=en&gl=US") - html = XML.parse_html(response.body) - ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] - next ucid if ucid - end - - nil - end - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions) - - Invidious::Database::Users.update_subscriptions(user) + Invidious::User::Import.from_newpipe_subs(user, body) when "import_newpipe" - Compress::Zip::Reader.open(IO::Memory.new(body)) do |file| - file.each_entry do |entry| - if entry.filename == "newpipe.db" - tempfile = File.tempfile(".db") - File.write(tempfile.path, entry.io.gets_to_end) - db = DB.open("sqlite3://" + tempfile.path) - - user.watched += db.query_all("SELECT url FROM streams", as: String).map(&.lchop("https://www.youtube.com/watch?v=")) - user.watched.uniq! + success = Invidious::User::Import.from_newpipe(user, body) - Invidious::Database::Users.update_watch_history(user) - - user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String).map(&.lchop("https://www.youtube.com/channel/")) - user.subscriptions.uniq! - - user.subscriptions = get_batch_channels(user.subscriptions) - - Invidious::Database::Users.update_subscriptions(user) - - db.close - tempfile.delete - end - end + if !success + haltf(env, status_code: 415, + response: error_template(415, "Uploaded file is too large") + ) end else nil # Ignore end diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index c8580038..7404cd97 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -29,5 +29,194 @@ struct Invidious::User return subscriptions end - end + + # ------------------- + # Invidious + # ------------------- + + # Import from another invidious account + def from_invidious(user : User, body : String) + data = JSON.parse(body) + + if data["subscriptions"]? + user.subscriptions += data["subscriptions"].as_a.map(&.as_s) + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + end + + if data["watch_history"]? + user.watched += data["watch_history"].as_a.map(&.as_s) + user.watched.uniq! + Invidious::Database::Users.update_watch_history(user) + end + + if data["preferences"]? + user.preferences = Preferences.from_json(data["preferences"].to_json) + Invidious::Database::Users.update_preferences(user) + end + + if playlists = data["playlists"]?.try &.as_a? + playlists.each do |item| + title = item["title"]?.try &.as_s?.try &.delete("<>") + description = item["description"]?.try &.as_s?.try &.delete("\r") + privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy } + + next if !title + next if !description + next if !privacy + + playlist = create_playlist(title, privacy, user) + Invidious::Database::Playlists.update_description(playlist.id, description) + + videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| + raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 + + video_id = video_id.try &.as_s? + next if !video_id + + begin + video = get_video(video_id) + rescue ex + next + 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), + }) + + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) + end + end + end + end + + # ------------------- + # Youtube + # ------------------- + + # Import subscribed channels from Youtube + # Returns success status + def from_youtube(user : User, body : String, filename : String, type : String) : Bool + extension = filename.split(".").last + + if extension == "xml" || type == "application/xml" || type == "text/xml" + subscriptions = XML.parse(body) + user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| + channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] + end + elsif extension == "json" || type == "application/json" + subscriptions = JSON.parse(body) + user.subscriptions += subscriptions.as_a.compact_map do |entry| + entry["snippet"]["resourceId"]["channelId"].as_s + end + elsif extension == "csv" || type == "text/csv" + subscriptions = parse_subscription_export_csv(body) + user.subscriptions += subscriptions + else + return false + end + + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + return true + end + + # ------------------- + # Freetube + # ------------------- + + def from_freetube(user : User, body : String) + matches = body.scan(/"channelId":"(?[a-zA-Z0-9_-]{24})"/) + + user.subscriptions += matches.map(&.["channel_id"]) + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + end + + # ------------------- + # Newpipe + # ------------------- + + def from_newpipe_subs(user : User, body : String) + data = JSON.parse(body) + + user.subscriptions += data["subscriptions"].as_a.compact_map do |channel| + if match = channel["url"].as_s.match(/\/channel\/(?UC[a-zA-Z0-9_-]{22})/) + next match["channel"] + elsif match = channel["url"].as_s.match(/\/user\/(?.+)/) + # Resolve URL using the API + resolved_url = YoutubeAPI.resolve_url("https://www.youtube.com/user/#{match["user"]}") + ucid = resolved_url.dig?("endpoint", "browseEndpoint", "browseId") + next ucid.as_s if ucid + end + + nil + end + + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + end + + def from_newpipe(user : User, body : String) : Bool + io = IO::Memory.new(body) + + Compress::Zip::File.open(io) do |file| + file.entries.each do |entry| + entry.open do |file_io| + # Ensure max size of 4MB + io_sized = IO::Sized.new(file_io, 0x400000) + + next if entry.filename != "newpipe.db" + + tempfile = File.tempfile(".db") + + begin + File.write(tempfile.path, io_sized.gets_to_end) + rescue + return false + end + + db = DB.open("sqlite3://" + tempfile.path) + + user.watched += db.query_all("SELECT url FROM streams", as: String) + .map(&.lchop("https://www.youtube.com/watch?v=")) + + user.watched.uniq! + Invidious::Database::Users.update_watch_history(user) + + user.subscriptions += db.query_all("SELECT url FROM subscriptions", as: String) + .map(&.lchop("https://www.youtube.com/channel/")) + + user.subscriptions.uniq! + user.subscriptions = get_batch_channels(user.subscriptions) + + Invidious::Database::Users.update_subscriptions(user) + + db.close + tempfile.delete + end + end + end + + # Success! + return true + end + end # module end -- cgit v1.2.3 From 71a8867a4a719023230802f2927617d676bfa0b4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 4 Feb 2022 06:43:43 +0100 Subject: Move user cookies to their own module --- src/invidious/routes/login.cr | 40 +++---------------------------------- src/invidious/routes/preferences.cr | 30 ++-------------------------- src/invidious/user/cookies.cr | 37 ++++++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 65 deletions(-) create mode 100644 src/invidious/user/cookies.cr (limited to 'src') diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 42ac0b1d..8767ec22 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -282,18 +282,8 @@ module Invidious::Routes::Login host = URI.parse(env.request.headers["Host"]).host - if Kemal.config.ssl || CONFIG.https_only - secure = true - else - secure = false - end - cookies.each do |cookie| - if Kemal.config.ssl || CONFIG.https_only - cookie.secure = secure - else - cookie.secure = secure - end + cookie.secure = Invidious::User::Cookies::SECURE if cookie.extension cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host) @@ -338,19 +328,7 @@ module Invidious::Routes::Login sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) Invidious::Database::SessionIDs.insert(sid, email) - if Kemal.config.ssl || CONFIG.https_only - secure = true - else - secure = false - end - - if CONFIG.domain - env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years, - secure: secure, http_only: true) - else - env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years, - secure: secure, http_only: true) - end + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) else return error_template(401, "Wrong username or password") end @@ -455,19 +433,7 @@ module Invidious::Routes::Login view_name = "subscriptions_#{sha256(user.email)}" PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - if Kemal.config.ssl || CONFIG.https_only - secure = true - else - secure = false - end - - if CONFIG.domain - env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", domain: "#{CONFIG.domain}", value: sid, expires: Time.utc + 2.years, - secure: secure, http_only: true) - else - env.response.cookies["SID"] = HTTP::Cookie.new(name: "SID", value: sid, expires: Time.utc + 2.years, - secure: secure, http_only: true) - end + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid) if env.request.cookies["PREFS"]? user.preferences = env.get("preferences").as(Preferences) diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index b574c1c1..294932eb 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -214,19 +214,7 @@ module Invidious::Routes::PreferencesRoute File.write("config/config.yml", CONFIG.to_yaml) end else - if Kemal.config.ssl || CONFIG.https_only - secure = true - else - secure = false - end - - if CONFIG.domain - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, - secure: secure, http_only: true) - else - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, - secure: secure, http_only: true) - end + env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences) end env.redirect referer @@ -261,21 +249,7 @@ module Invidious::Routes::PreferencesRoute preferences.dark_mode = "dark" end - preferences = preferences.to_json - - if Kemal.config.ssl || CONFIG.https_only - secure = true - else - secure = false - end - - if CONFIG.domain - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", domain: "#{CONFIG.domain}", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years, - secure: secure, http_only: true) - else - env.response.cookies["PREFS"] = HTTP::Cookie.new(name: "PREFS", value: URI.encode_www_form(preferences), expires: Time.utc + 2.years, - secure: secure, http_only: true) - end + env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences) end if redirect diff --git a/src/invidious/user/cookies.cr b/src/invidious/user/cookies.cr new file mode 100644 index 00000000..99df1b07 --- /dev/null +++ b/src/invidious/user/cookies.cr @@ -0,0 +1,37 @@ +require "http/cookie" + +struct Invidious::User + module Cookies + extend self + + # Note: we use ternary operator because the two variables + # used in here are not booleans. + SECURE = (Kemal.config.ssl || CONFIG.https_only) ? true : false + + # Session ID (SID) cookie + # Parameter "domain" comes from the global config + def sid(domain : String?, sid) : HTTP::Cookie + return HTTP::Cookie.new( + name: "SID", + domain: domain, + value: sid, + expires: Time.utc + 2.years, + secure: SECURE, + http_only: true + ) + end + + # Preferences (PREFS) cookie + # Parameter "domain" comes from the global config + def prefs(domain : String?, preferences : Preferences) : HTTP::Cookie + return HTTP::Cookie.new( + name: "PREFS", + domain: domain, + value: URI.encode_www_form(preferences.to_json), + expires: Time.utc + 2.years, + secure: SECURE, + http_only: true + ) + end + end +end -- cgit v1.2.3 From 99d770be643d36fe40fbb8ac54a5fa5f692aebf0 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 6 Feb 2022 21:51:02 +0100 Subject: Move user pages (ECR files) to subfolder --- src/invidious/routes/account.cr | 12 +- src/invidious/routes/api/v1/authenticated.cr | 2 +- src/invidious/routes/login.cr | 8 +- src/invidious/routes/preferences.cr | 4 +- src/invidious/routes/subscriptions.cr | 2 +- src/invidious/views/authorize_token.ecr | 78 ----- src/invidious/views/change_password.ecr | 32 -- src/invidious/views/clear_watch_history.ecr | 24 -- src/invidious/views/data_control.ecr | 58 ---- src/invidious/views/delete_account.ecr | 24 -- src/invidious/views/login.ecr | 113 ------- src/invidious/views/preferences.ecr | 351 ---------------------- src/invidious/views/subscription_manager.ecr | 54 ---- src/invidious/views/token_manager.ecr | 46 --- src/invidious/views/user/authorize_token.ecr | 78 +++++ src/invidious/views/user/change_password.ecr | 32 ++ src/invidious/views/user/clear_watch_history.ecr | 24 ++ src/invidious/views/user/data_control.ecr | 58 ++++ src/invidious/views/user/delete_account.ecr | 24 ++ src/invidious/views/user/login.ecr | 113 +++++++ src/invidious/views/user/preferences.ecr | 351 ++++++++++++++++++++++ src/invidious/views/user/subscription_manager.ecr | 54 ++++ src/invidious/views/user/token_manager.ecr | 46 +++ 23 files changed, 794 insertions(+), 794 deletions(-) delete mode 100644 src/invidious/views/authorize_token.ecr delete mode 100644 src/invidious/views/change_password.ecr delete mode 100644 src/invidious/views/clear_watch_history.ecr delete mode 100644 src/invidious/views/data_control.ecr delete mode 100644 src/invidious/views/delete_account.ecr delete mode 100644 src/invidious/views/login.ecr delete mode 100644 src/invidious/views/preferences.ecr delete mode 100644 src/invidious/views/subscription_manager.ecr delete mode 100644 src/invidious/views/token_manager.ecr create mode 100644 src/invidious/views/user/authorize_token.ecr create mode 100644 src/invidious/views/user/change_password.ecr create mode 100644 src/invidious/views/user/clear_watch_history.ecr create mode 100644 src/invidious/views/user/data_control.ecr create mode 100644 src/invidious/views/user/delete_account.ecr create mode 100644 src/invidious/views/user/login.ecr create mode 100644 src/invidious/views/user/preferences.ecr create mode 100644 src/invidious/views/user/subscription_manager.ecr create mode 100644 src/invidious/views/user/token_manager.ecr (limited to 'src') diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index 2be0de37..9bb73136 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -23,7 +23,7 @@ module Invidious::Routes::Account sid = sid.as(String) csrf_token = generate_response(sid, {":change_password"}, HMAC_KEY) - templated "change_password" + templated "user/change_password" end # Handle the password change (POST request) @@ -103,7 +103,7 @@ module Invidious::Routes::Account sid = sid.as(String) csrf_token = generate_response(sid, {":delete_account"}, HMAC_KEY) - templated "delete_account" + templated "user/delete_account" end # Handle the account deletion (POST request) @@ -161,7 +161,7 @@ module Invidious::Routes::Account sid = sid.as(String) csrf_token = generate_response(sid, {":clear_watch_history"}, HMAC_KEY) - templated "clear_watch_history" + templated "user/clear_watch_history" end # Handle the watch history clearing (POST request) @@ -220,7 +220,7 @@ module Invidious::Routes::Account expire = env.params.query["expire"]?.try &.to_i? - templated "authorize_token" + templated "user/authorize_token" end # Handle token authorization (POST request) @@ -268,7 +268,7 @@ module Invidious::Routes::Account else csrf_token = "" env.set "access_token", access_token - templated "authorize_token" + templated "user/authorize_token" end end @@ -291,7 +291,7 @@ module Invidious::Routes::Account user = user.as(User) tokens = Invidious::Database::SessionIDs.select_all(user.email) - templated "token_manager" + templated "user/token_manager" end # ------------------- diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 4e9fc801..c27853ca 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -343,7 +343,7 @@ module Invidious::Routes::API::V1::Authenticated env.response.content_type = "text/html" csrf_token = generate_response(sid, {":authorize_token"}, HMAC_KEY, use_nonce: true) - return templated "authorize_token" + return templated "user/authorize_token" else env.response.content_type = "application/json" diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 8767ec22..65b337d1 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -27,7 +27,7 @@ module Invidious::Routes::Login tfa = env.params.query["tfa"]? prompt = nil - templated "login" + templated "user/login" end def self.login(env) @@ -133,7 +133,7 @@ module Invidious::Routes::Login tfa = tfa_code captcha = {tokens: [token], question: ""} - return templated "login" + return templated "user/login" end if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" @@ -190,7 +190,7 @@ module Invidious::Routes::Login tfa = nil captcha = nil - return templated "login" + return templated "user/login" end tl = challenge_results[1][2] @@ -376,7 +376,7 @@ module Invidious::Routes::Login captcha = Invidious::User::Captcha.generate_text(HMAC_KEY) end - return templated "login" + return templated "user/login" end tokens = env.params.body.select { |k, _| k.match(/^token\[\d+\]$/) }.map { |_, v| v } diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 294932eb..68d61fd1 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -8,7 +8,7 @@ module Invidious::Routes::PreferencesRoute preferences = env.get("preferences").as(Preferences) - templated "preferences" + templated "user/preferences" end def self.update(env) @@ -272,7 +272,7 @@ module Invidious::Routes::PreferencesRoute user = user.as(User) - templated "data_control" + templated "user/data_control" end def self.update_data_control(env) diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr index ec8fe67b..7b1fa876 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -163,6 +163,6 @@ module Invidious::Routes::Subscriptions end end - templated "subscription_manager" + templated "user/subscription_manager" end end diff --git a/src/invidious/views/authorize_token.ecr b/src/invidious/views/authorize_token.ecr deleted file mode 100644 index 725f392e..00000000 --- a/src/invidious/views/authorize_token.ecr +++ /dev/null @@ -1,78 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Token") %> - Invidious -<% end %> - -<% if env.get? "access_token" %> - - -
    -

    - <%= env.get "access_token" %> -

    -
    -<% else %> -
    - - <% if callback_url %> - <%= translate(locale, "Authorize token for `x`?", "#{callback_url.scheme}://#{callback_url.host}") %> - <% else %> - <%= translate(locale, "Authorize token?") %> - <% end %> - -
    -
    -
      - <% scopes.each do |scope| %> -
    • <%= HTML.escape(scope) %>
    • - <% end %> -
    -
    -
    - -
    -
    - -
    - -
    - - <% scopes.each_with_index do |scope, i| %> - - <% end %> - <% if callback_url %> - - <% end %> - <% if expire %> - - <% end %> - - - -
    -<% end %> diff --git a/src/invidious/views/change_password.ecr b/src/invidious/views/change_password.ecr deleted file mode 100644 index 1b9eb82e..00000000 --- a/src/invidious/views/change_password.ecr +++ /dev/null @@ -1,32 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Change password") %> - Invidious -<% end %> - -
    -
    -
    -
    -
    - <%= translate(locale, "Change password") %> - -
    - - "> - - - "> - - - "> - - - - -
    -
    -
    -
    -
    -
    diff --git a/src/invidious/views/clear_watch_history.ecr b/src/invidious/views/clear_watch_history.ecr deleted file mode 100644 index c9acbe44..00000000 --- a/src/invidious/views/clear_watch_history.ecr +++ /dev/null @@ -1,24 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Clear watch history") %> - Invidious -<% end %> - -
    -
    - <%= translate(locale, "Clear watch history?") %> - -
    -
    - -
    - -
    - - -
    -
    diff --git a/src/invidious/views/data_control.ecr b/src/invidious/views/data_control.ecr deleted file mode 100644 index 74ccc06c..00000000 --- a/src/invidious/views/data_control.ecr +++ /dev/null @@ -1,58 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Import and Export Data") %> - Invidious -<% end %> - -
    -
    -
    - <%= translate(locale, "Import") %> - -
    - - -
    - - - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - -
    - - <%= translate(locale, "Export") %> - - - - - - -
    -
    -
    diff --git a/src/invidious/views/delete_account.ecr b/src/invidious/views/delete_account.ecr deleted file mode 100644 index 67351bbf..00000000 --- a/src/invidious/views/delete_account.ecr +++ /dev/null @@ -1,24 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Delete account") %> - Invidious -<% end %> - -
    -
    - <%= translate(locale, "Delete account?") %> - -
    -
    - -
    - -
    - - -
    -
    diff --git a/src/invidious/views/login.ecr b/src/invidious/views/login.ecr deleted file mode 100644 index 01d7a210..00000000 --- a/src/invidious/views/login.ecr +++ /dev/null @@ -1,113 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Log in") %> - Invidious -<% end %> - -
    -
    -
    -
    - <% case account_type when %> - <% when "google" %> -
    -
    - <% if email %> - - <% else %> - - "> - <% end %> - - <% if password %> - - <% else %> - - "> - <% end %> - - <% if prompt %> - - - <% end %> - - <% if tfa %> - - <% end %> - - <% if captcha %> - - - - "> - <% end %> - - -
    -
    - <% else # "invidious" %> -
    -
    - <% if email %> - - <% else %> - - "> - <% end %> - - <% if password %> - - <% else %> - - "> - <% end %> - - <% if captcha %> - <% case captcha_type when %> - <% when "image" %> - <% captcha = captcha.not_nil! %> - - <% captcha[:tokens].each_with_index do |token, i| %> - - <% end %> - - - - <% else # "text" %> - <% captcha = captcha.not_nil! %> - <% captcha[:tokens].each_with_index do |token, i| %> - - <% end %> - - - "> - <% end %> - - - - <% case captcha_type when %> - <% when "image" %> - - <% else # "text" %> - - <% end %> - <% else %> - - <% end %> -
    -
    - <% end %> -
    -
    -
    -
    diff --git a/src/invidious/views/preferences.ecr b/src/invidious/views/preferences.ecr deleted file mode 100644 index 3606d140..00000000 --- a/src/invidious/views/preferences.ecr +++ /dev/null @@ -1,351 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Preferences") %> - Invidious -<% end %> - -
    -
    -
    - <%= translate(locale, "preferences_category_player") %> - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %> <% if CONFIG.disabled?("local") %>disabled<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - -
    - -
    - - -
    - - <% if !CONFIG.disabled?("dash") %> -
    - - -
    - <% end %> - -
    - - - <%= preferences.volume %> -
    - -
    - - <% preferences.comments.each_with_index do |comments, index| %> - - <% end %> -
    - -
    - - <% preferences.captions.each_with_index do |caption, index| %> - - <% end %> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - - <%= translate(locale, "preferences_category_visual") %> - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - -
    - -
    - - checked<% end %>> -
    - - <% if env.get?("user") %> - <% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists"} %> - <% else %> - <% feed_options = {"", "Popular", "Trending"} %> - <% end %> - -
    - - -
    - -
    - - <% (feed_options.size - 1).times do |index| %> - - <% end %> -
    - <% if env.get? "user" %> -
    - - checked<% end %>> -
    - <% end %> - - <%= translate(locale, "preferences_category_misc") %> - -
    - - checked<% end %>> -
    - - <% if env.get? "user" %> - <%= translate(locale, "preferences_category_subscription") %> - -
    - - checked<% end %>> -
    - -
    - - -
    - -
    - - -
    - -
    - <% if preferences.unseen_only %> - - <% else %> - - <% end %> - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - - <% # Web notifications are only supported over HTTPS %> - <% if Kemal.config.ssl || CONFIG.https_only %> - - <% end %> - <% end %> - - <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %> - <%= translate(locale, "preferences_category_admin") %> - -
    - - -
    - -
    - - <% (feed_options.size - 1).times do |index| %> - - <% end %> -
    - -
    - - checked<% end %>> -
    - - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - -
    - - checked<% end %>> -
    - <% end %> - - <% if env.get? "user" %> - <%= translate(locale, "preferences_category_data") %> - - - - - - - - - - - - - - - - - <% end %> - -
    - -
    -
    -
    -
    diff --git a/src/invidious/views/subscription_manager.ecr b/src/invidious/views/subscription_manager.ecr deleted file mode 100644 index c2a89ca2..00000000 --- a/src/invidious/views/subscription_manager.ecr +++ /dev/null @@ -1,54 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Subscription manager") %> - Invidious -<% end %> - - - -<% subscriptions.each do |channel| %> -
    -
    - -
    -
    -

    -
    " method="post"> - "> - - "> - -
    -

    -
    -
    - - <% if subscriptions[-1].author != channel.author %> -
    - <% end %> -
    -<% end %> diff --git a/src/invidious/views/token_manager.ecr b/src/invidious/views/token_manager.ecr deleted file mode 100644 index 79f905a1..00000000 --- a/src/invidious/views/token_manager.ecr +++ /dev/null @@ -1,46 +0,0 @@ -<% content_for "header" do %> -<%= translate(locale, "Token manager") %> - Invidious -<% end %> - -
    -
    -

    - <%= translate_count(locale, "tokens_count", tokens.size, NumberFormatting::HtmlSpan) %> -

    -
    -
    - -
    - -<% tokens.each do |token| %> -
    -
    -
    -

    - <%= token[:session] %> -

    -
    -
    -

    <%= translate(locale, "`x` ago", recode_date(token[:issued], locale)) %>

    -
    -
    -

    -
    " method="post"> - "> - - "> - -
    -

    -
    -
    - - <% if tokens[-1].try &.[:session]? != token[:session] %> -
    - <% end %> -
    -<% end %> diff --git a/src/invidious/views/user/authorize_token.ecr b/src/invidious/views/user/authorize_token.ecr new file mode 100644 index 00000000..725f392e --- /dev/null +++ b/src/invidious/views/user/authorize_token.ecr @@ -0,0 +1,78 @@ +<% content_for "header" do %> +<%= translate(locale, "Token") %> - Invidious +<% end %> + +<% if env.get? "access_token" %> +
    +
    +

    + <%= translate(locale, "Token") %> +

    +
    + + +
    + +
    +

    + <%= env.get "access_token" %> +

    +
    +<% else %> +
    +
    + <% if callback_url %> + <%= translate(locale, "Authorize token for `x`?", "#{callback_url.scheme}://#{callback_url.host}") %> + <% else %> + <%= translate(locale, "Authorize token?") %> + <% end %> + +
    +
    +
      + <% scopes.each do |scope| %> +
    • <%= HTML.escape(scope) %>
    • + <% end %> +
    +
    +
    + +
    +
    + +
    + +
    + + <% scopes.each_with_index do |scope, i| %> + + <% end %> + <% if callback_url %> + + <% end %> + <% if expire %> + + <% end %> + + +
    +
    +<% end %> diff --git a/src/invidious/views/user/change_password.ecr b/src/invidious/views/user/change_password.ecr new file mode 100644 index 00000000..1b9eb82e --- /dev/null +++ b/src/invidious/views/user/change_password.ecr @@ -0,0 +1,32 @@ +<% content_for "header" do %> +<%= translate(locale, "Change password") %> - Invidious +<% end %> + +
    +
    +
    +
    +
    + <%= translate(locale, "Change password") %> + +
    + + "> + + + "> + + + "> + + + + +
    +
    +
    +
    +
    +
    diff --git a/src/invidious/views/user/clear_watch_history.ecr b/src/invidious/views/user/clear_watch_history.ecr new file mode 100644 index 00000000..c9acbe44 --- /dev/null +++ b/src/invidious/views/user/clear_watch_history.ecr @@ -0,0 +1,24 @@ +<% content_for "header" do %> +<%= translate(locale, "Clear watch history") %> - Invidious +<% end %> + +
    +
    + <%= translate(locale, "Clear watch history?") %> + +
    +
    + +
    + +
    + + +
    +
    diff --git a/src/invidious/views/user/data_control.ecr b/src/invidious/views/user/data_control.ecr new file mode 100644 index 00000000..74ccc06c --- /dev/null +++ b/src/invidious/views/user/data_control.ecr @@ -0,0 +1,58 @@ +<% content_for "header" do %> +<%= translate(locale, "Import and Export Data") %> - Invidious +<% end %> + +
    +
    +
    + <%= translate(locale, "Import") %> + +
    + + +
    + + + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + +
    + + <%= translate(locale, "Export") %> + + + + + + +
    +
    +
    diff --git a/src/invidious/views/user/delete_account.ecr b/src/invidious/views/user/delete_account.ecr new file mode 100644 index 00000000..67351bbf --- /dev/null +++ b/src/invidious/views/user/delete_account.ecr @@ -0,0 +1,24 @@ +<% content_for "header" do %> +<%= translate(locale, "Delete account") %> - Invidious +<% end %> + +
    +
    + <%= translate(locale, "Delete account?") %> + +
    +
    + +
    + +
    + + +
    +
    diff --git a/src/invidious/views/user/login.ecr b/src/invidious/views/user/login.ecr new file mode 100644 index 00000000..01d7a210 --- /dev/null +++ b/src/invidious/views/user/login.ecr @@ -0,0 +1,113 @@ +<% content_for "header" do %> +<%= translate(locale, "Log in") %> - Invidious +<% end %> + +
    +
    +
    +
    + <% case account_type when %> + <% when "google" %> +
    +
    + <% if email %> + + <% else %> + + "> + <% end %> + + <% if password %> + + <% else %> + + "> + <% end %> + + <% if prompt %> + + + <% end %> + + <% if tfa %> + + <% end %> + + <% if captcha %> + + + + "> + <% end %> + + +
    +
    + <% else # "invidious" %> +
    +
    + <% if email %> + + <% else %> + + "> + <% end %> + + <% if password %> + + <% else %> + + "> + <% end %> + + <% if captcha %> + <% case captcha_type when %> + <% when "image" %> + <% captcha = captcha.not_nil! %> + + <% captcha[:tokens].each_with_index do |token, i| %> + + <% end %> + + + + <% else # "text" %> + <% captcha = captcha.not_nil! %> + <% captcha[:tokens].each_with_index do |token, i| %> + + <% end %> + + + "> + <% end %> + + + + <% case captcha_type when %> + <% when "image" %> + + <% else # "text" %> + + <% end %> + <% else %> + + <% end %> +
    +
    + <% end %> +
    +
    +
    +
    diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr new file mode 100644 index 00000000..3606d140 --- /dev/null +++ b/src/invidious/views/user/preferences.ecr @@ -0,0 +1,351 @@ +<% content_for "header" do %> +<%= translate(locale, "Preferences") %> - Invidious +<% end %> + +
    +
    +
    + <%= translate(locale, "preferences_category_player") %> + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %> <% if CONFIG.disabled?("local") %>disabled<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + +
    + +
    + + +
    + + <% if !CONFIG.disabled?("dash") %> +
    + + +
    + <% end %> + +
    + + + <%= preferences.volume %> +
    + +
    + + <% preferences.comments.each_with_index do |comments, index| %> + + <% end %> +
    + +
    + + <% preferences.captions.each_with_index do |caption, index| %> + + <% end %> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + + <%= translate(locale, "preferences_category_visual") %> + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + checked<% end %>> +
    + + <% if env.get?("user") %> + <% feed_options = {"", "Popular", "Trending", "Subscriptions", "Playlists"} %> + <% else %> + <% feed_options = {"", "Popular", "Trending"} %> + <% end %> + +
    + + +
    + +
    + + <% (feed_options.size - 1).times do |index| %> + + <% end %> +
    + <% if env.get? "user" %> +
    + + checked<% end %>> +
    + <% end %> + + <%= translate(locale, "preferences_category_misc") %> + +
    + + checked<% end %>> +
    + + <% if env.get? "user" %> + <%= translate(locale, "preferences_category_subscription") %> + +
    + + checked<% end %>> +
    + +
    + + +
    + +
    + + +
    + +
    + <% if preferences.unseen_only %> + + <% else %> + + <% end %> + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + + <% # Web notifications are only supported over HTTPS %> + <% if Kemal.config.ssl || CONFIG.https_only %> + + <% end %> + <% end %> + + <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %> + <%= translate(locale, "preferences_category_admin") %> + +
    + + +
    + +
    + + <% (feed_options.size - 1).times do |index| %> + + <% end %> +
    + +
    + + checked<% end %>> +
    + + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + +
    + + checked<% end %>> +
    + <% end %> + + <% if env.get? "user" %> + <%= translate(locale, "preferences_category_data") %> + + + + + + + + + + + + + + + + + <% end %> + +
    + +
    +
    +
    +
    diff --git a/src/invidious/views/user/subscription_manager.ecr b/src/invidious/views/user/subscription_manager.ecr new file mode 100644 index 00000000..c2a89ca2 --- /dev/null +++ b/src/invidious/views/user/subscription_manager.ecr @@ -0,0 +1,54 @@ +<% content_for "header" do %> +<%= translate(locale, "Subscription manager") %> - Invidious +<% end %> + + + +<% subscriptions.each do |channel| %> +
    +
    + +
    +
    +

    +
    " method="post"> + "> + + "> + +
    +

    +
    +
    + + <% if subscriptions[-1].author != channel.author %> +
    + <% end %> +
    +<% end %> diff --git a/src/invidious/views/user/token_manager.ecr b/src/invidious/views/user/token_manager.ecr new file mode 100644 index 00000000..79f905a1 --- /dev/null +++ b/src/invidious/views/user/token_manager.ecr @@ -0,0 +1,46 @@ +<% content_for "header" do %> +<%= translate(locale, "Token manager") %> - Invidious +<% end %> + +
    +
    +

    + <%= translate_count(locale, "tokens_count", tokens.size, NumberFormatting::HtmlSpan) %> +

    +
    +
    + +
    + +<% tokens.each do |token| %> +
    +
    +
    +

    + <%= token[:session] %> +

    +
    +
    +

    <%= translate(locale, "`x` ago", recode_date(token[:issued], locale)) %>

    +
    +
    +

    +
    " method="post"> + "> + + "> + +
    +

    +
    +
    + + <% if tokens[-1].try &.[:session]? != token[:session] %> +
    + <% end %> +
    +<% end %> -- cgit v1.2.3 From d12dff9dcfd2d85405375f6ab8282acd19b90035 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 7 Feb 2022 22:18:24 +0100 Subject: Use a regex to fix badly aligned captions --- src/invidious/routes/api/v1/videos.cr | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 86eb26ee..2b23d2ad 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -130,7 +130,13 @@ module Invidious::Routes::API::V1::Videos end end else + # Some captions have "align:[start/end]" and "position:[num]%" + # attributes. Those are causing issues with VideoJS, which is unable + # to properly align the captions on the video, so we remove them. + # + # See: https://github.com/iv-org/invidious/issues/2391 webvtt = YT_POOL.client &.get("#{url}&format=vtt").body + .gsub(/([0-9:.]+ --> [0-9:.]+).+/, "\\1") end if title = env.params.query["title"]? -- cgit v1.2.3 From f73aef33f013d79d59c069c9e6eb5ec1b8c610d9 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 7 Feb 2022 22:45:08 +0100 Subject: Add compile option to disable fetching of player dependencies --- Makefile | 3 ++- src/invidious.cr | 22 ++++++++++++---------- 2 files changed, 14 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/Makefile b/Makefile index ef6c4e16..a82d76b4 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,8 @@ test: crystal spec verify: - crystal build src/invidious.cr --no-codegen --progress --stats --error-trace + crystal build src/invidious.cr -Ddont_fetch_videojs \ + --no-codegen --progress --stats --error-trace # ----------------------- diff --git a/src/invidious.cr b/src/invidious.cr index f4cae7ea..79429404 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -114,16 +114,18 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) # Check table integrity Invidious::Database.check_integrity(CONFIG) -# Resolve player dependencies. This is done at compile time. -# -# Running the script by itself would show some colorful feedback while this doesn't. -# Perhaps we should just move the script to runtime in order to get that feedback? - -{% puts "\nChecking player dependencies...\n" %} -{% if flag?(:minified_player_dependencies) %} - {% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %} -{% else %} - {% puts run("../scripts/fetch-player-dependencies.cr").stringify %} +{% unless flag?(:dont_fetch_videojs) %} + # Resolve player dependencies. This is done at compile time. + # + # Running the script by itself would show some colorful feedback while this doesn't. + # Perhaps we should just move the script to runtime in order to get that feedback? + + {% puts "\nChecking player dependencies...\n" %} + {% if flag?(:minified_player_dependencies) %} + {% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %} + {% else %} + {% puts run("../scripts/fetch-player-dependencies.cr").stringify %} + {% end %} {% end %} # Start jobs -- cgit v1.2.3 From 76cc8ac66b88bb7ac632eee2e06d2557ec086151 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 8 Feb 2022 01:48:44 +0100 Subject: HTML escape error message --- src/invidious/helpers/errors.cr | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 3acbac84..6155e561 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -38,12 +38,15 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce issue_title = "#{exception.message} (#{exception.class})" - issue_template = %(Title: `#{issue_title}`) - issue_template += %(\nDate: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}`) - issue_template += %(\nRoute: `#{env.request.resource}`) - issue_template += %(\nVersion: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}`) - # issue_template += github_details("Preferences", env.get("preferences").as(Preferences).to_pretty_json) - issue_template += github_details("Backtrace", exception.inspect_with_backtrace) + issue_template = <<-TEXT + Title: `#{HTML.escape(issue_title)}` + Date: `#{Time::Format::ISO_8601_DATE_TIME.format(Time.utc)}` + Route: `#{HTML.escape(env.request.resource)}` + Version: `#{SOFTWARE["version"]} @ #{SOFTWARE["branch"]}` + + TEXT + + issue_template += github_details("Backtrace", HTML.escape(exception.inspect_with_backtrace)) # URLs for the error message below url_faq = "https://github.com/iv-org/documentation/blob/master/FAQ.md" -- cgit v1.2.3 From febd785428809f84ba3ab6a0d311af40d7f30a83 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 8 Feb 2022 02:13:14 +0100 Subject: Add missing subtitle languages --- locales/en-US.json | 23 +++++++++++++++++++++++ src/invidious/videos.cr | 23 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 83d06cce..c7f6e178 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -236,6 +236,8 @@ "No such user": "No such user", "Token is expired, please try again": "Token is expired, please try again", "English": "English", + "English (United Kingdom)": "English (United Kingdom)", + "English (United States)": "English (United States)", "English (auto-generated)": "English (auto-generated)", "Afrikaans": "Afrikaans", "Albanian": "Albanian", @@ -249,23 +251,31 @@ "Bosnian": "Bosnian", "Bulgarian": "Bulgarian", "Burmese": "Burmese", + "Cantonese (Hong Kong)": "Cantonese (Hong Kong)", "Catalan": "Catalan", "Cebuano": "Cebuano", + "Chinese": "Chinese", + "Chinese (China)": "Chinese (China)", + "Chinese (Hong Kong)": "Chinese (Hong Kong)", "Chinese (Simplified)": "Chinese (Simplified)", + "Chinese (Taiwan)": "Chinese (Taiwan)", "Chinese (Traditional)": "Chinese (Traditional)", "Corsican": "Corsican", "Croatian": "Croatian", "Czech": "Czech", "Danish": "Danish", "Dutch": "Dutch", + "Dutch (auto-generated)": "Dutch (auto-generated)", "Esperanto": "Esperanto", "Estonian": "Estonian", "Filipino": "Filipino", "Finnish": "Finnish", "French": "French", + "French (auto-generated)": "French (auto-generated)", "Galician": "Galician", "Georgian": "Georgian", "German": "German", + "German (auto-generated)": "German (auto-generated)", "Greek": "Greek", "Gujarati": "Gujarati", "Haitian Creole": "Haitian Creole", @@ -278,14 +288,19 @@ "Icelandic": "Icelandic", "Igbo": "Igbo", "Indonesian": "Indonesian", + "Indonesian (auto-generated)": "Indonesian (auto-generated)", + "Interlingue": "Interlingue", "Irish": "Irish", "Italian": "Italian", + "Italian (auto-generated)": "Italian (auto-generated)", "Japanese": "Japanese", + "Japanese (auto-generated)": "Japanese (auto-generated)", "Javanese": "Javanese", "Kannada": "Kannada", "Kazakh": "Kazakh", "Khmer": "Khmer", "Korean": "Korean", + "Korean (auto-generated)": "Korean (auto-generated)", "Kurdish": "Kurdish", "Kyrgyz": "Kyrgyz", "Lao": "Lao", @@ -308,9 +323,12 @@ "Persian": "Persian", "Polish": "Polish", "Portuguese": "Portuguese", + "Portuguese (auto-generated)": "Portuguese (auto-generated)", + "Portuguese (Brazil)": "Portuguese (Brazil)", "Punjabi": "Punjabi", "Romanian": "Romanian", "Russian": "Russian", + "Russian (auto-generated)": "Russian (auto-generated)", "Samoan": "Samoan", "Scottish Gaelic": "Scottish Gaelic", "Serbian": "Serbian", @@ -322,7 +340,10 @@ "Somali": "Somali", "Southern Sotho": "Southern Sotho", "Spanish": "Spanish", + "Spanish (auto-generated)": "Spanish (auto-generated)", "Spanish (Latin America)": "Spanish (Latin America)", + "Spanish (Mexico)": "Spanish (Mexico)", + "Spanish (Spain)": "Spanish (Spain)", "Sundanese": "Sundanese", "Swahili": "Swahili", "Swedish": "Swedish", @@ -331,10 +352,12 @@ "Telugu": "Telugu", "Thai": "Thai", "Turkish": "Turkish", + "Turkish (auto-generated)": "Turkish (auto-generated)", "Ukrainian": "Ukrainian", "Urdu": "Urdu", "Uzbek": "Uzbek", "Vietnamese": "Vietnamese", + "Vietnamese (auto-generated)": "Vietnamese (auto-generated)", "Welsh": "Welsh", "Western Frisian": "Western Frisian", "Xhosa": "Xhosa", diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 446e8e03..335f6b60 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -2,6 +2,8 @@ CAPTION_LANGUAGES = { "", "English", "English (auto-generated)", + "English (United Kingdom)", + "English (United States)", "Afrikaans", "Albanian", "Amharic", @@ -14,23 +16,31 @@ CAPTION_LANGUAGES = { "Bosnian", "Bulgarian", "Burmese", + "Cantonese (Hong Kong)", "Catalan", "Cebuano", + "Chinese", + "Chinese (China)", + "Chinese (Hong Kong)", "Chinese (Simplified)", + "Chinese (Taiwan)", "Chinese (Traditional)", "Corsican", "Croatian", "Czech", "Danish", "Dutch", + "Dutch (auto-generated)", "Esperanto", "Estonian", "Filipino", "Finnish", "French", + "French (auto-generated)", "Galician", "Georgian", "German", + "German (auto-generated)", "Greek", "Gujarati", "Haitian Creole", @@ -43,14 +53,19 @@ CAPTION_LANGUAGES = { "Icelandic", "Igbo", "Indonesian", + "Indonesian (auto-generated)", + "Interlingue", "Irish", "Italian", + "Italian (auto-generated)", "Japanese", + "Japanese (auto-generated)", "Javanese", "Kannada", "Kazakh", "Khmer", "Korean", + "Korean (auto-generated)", "Kurdish", "Kyrgyz", "Lao", @@ -73,9 +88,12 @@ CAPTION_LANGUAGES = { "Persian", "Polish", "Portuguese", + "Portuguese (auto-generated)", + "Portuguese (Brazil)", "Punjabi", "Romanian", "Russian", + "Russian (auto-generated)", "Samoan", "Scottish Gaelic", "Serbian", @@ -87,7 +105,10 @@ CAPTION_LANGUAGES = { "Somali", "Southern Sotho", "Spanish", + "Spanish (auto-generated)", "Spanish (Latin America)", + "Spanish (Mexico)", + "Spanish (Spain)", "Sundanese", "Swahili", "Swedish", @@ -96,10 +117,12 @@ CAPTION_LANGUAGES = { "Telugu", "Thai", "Turkish", + "Turkish (auto-generated)", "Ukrainian", "Urdu", "Uzbek", "Vietnamese", + "Vietnamese (auto-generated)", "Welsh", "Western Frisian", "Xhosa", -- cgit v1.2.3 From 4f4b19a962999ad191b1312e53aa44c8180d22fa Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 8 Feb 2022 02:41:08 +0100 Subject: embed page: fix typo in videojs-overlay script URL --- src/invidious/views/embed.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index cd0fd0d5..27a8e266 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -7,7 +7,7 @@ <%= rendered "components/player_sources" %> - + <%= HTML.escape(video.title) %> - Invidious -- cgit v1.2.3 From 492d1144e0dff85bc58071037ef0980c4027cc69 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 8 Feb 2022 03:05:49 +0100 Subject: Apply changes from code review --- Makefile | 2 +- src/invidious.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/Makefile b/Makefile index a82d76b4..7f56d722 100644 --- a/Makefile +++ b/Makefile @@ -62,7 +62,7 @@ test: crystal spec verify: - crystal build src/invidious.cr -Ddont_fetch_videojs \ + crystal build src/invidious.cr -Dskip_videojs_download \ --no-codegen --progress --stats --error-trace diff --git a/src/invidious.cr b/src/invidious.cr index 79429404..1ff70905 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -114,7 +114,7 @@ LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) # Check table integrity Invidious::Database.check_integrity(CONFIG) -{% unless flag?(:dont_fetch_videojs) %} +{% if !flag?(:skip_videojs_download) %} # Resolve player dependencies. This is done at compile time. # # Running the script by itself would show some colorful feedback while this doesn't. -- cgit v1.2.3 From ec55b905cb8b0b65deeb21a89751b17160676159 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 9 Feb 2022 01:36:17 +0100 Subject: Fix empty error page on BrokenTubeException --- src/invidious/exceptions.cr | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index 391a574d..490d98cd 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -1,8 +1,12 @@ # Exception used to hold the name of the missing item # Should be used in all parsing functions -class BrokenTubeException < InfoException +class BrokenTubeException < Exception getter element : String def initialize(@element) end + + def message + return "Missing JSON element \"#{@element}\"" + end end -- cgit v1.2.3 From 8ec992a8a31742a82de38a0aa5eeb509362da9b4 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Wed, 9 Feb 2022 00:50:32 -0600 Subject: Add custom migration implementation --- src/invidious.cr | 3 ++ src/invidious/migration.cr | 38 +++++++++++++++++ .../migrations/0000_create_channels_table.cr | 30 ++++++++++++++ .../migrations/0001_create_videos_table.cr | 28 +++++++++++++ .../migrations/0002_create_channel_videos_table.cr | 35 ++++++++++++++++ .../migrations/0003_create_users_table.cr | 34 ++++++++++++++++ .../migrations/0004_create_session_ids_table.cr | 28 +++++++++++++ .../migrations/0005_create_nonces_table.cr | 27 +++++++++++++ .../migrations/0006_create_annotations_table.cr | 20 +++++++++ .../migrations/0007_create_playlists_table.cr | 47 ++++++++++++++++++++++ .../0008_create_playlist_videos_table.cr | 27 +++++++++++++ src/invidious/migrator.cr | 41 +++++++++++++++++++ 12 files changed, 358 insertions(+) create mode 100644 src/invidious/migration.cr create mode 100644 src/invidious/migrations/0000_create_channels_table.cr create mode 100644 src/invidious/migrations/0001_create_videos_table.cr create mode 100644 src/invidious/migrations/0002_create_channel_videos_table.cr create mode 100644 src/invidious/migrations/0003_create_users_table.cr create mode 100644 src/invidious/migrations/0004_create_session_ids_table.cr create mode 100644 src/invidious/migrations/0005_create_nonces_table.cr create mode 100644 src/invidious/migrations/0006_create_annotations_table.cr create mode 100644 src/invidious/migrations/0007_create_playlists_table.cr create mode 100644 src/invidious/migrations/0008_create_playlist_videos_table.cr create mode 100644 src/invidious/migrator.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 1ff70905..6ec5f3a5 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -34,6 +34,7 @@ require "./invidious/channels/*" require "./invidious/user/*" require "./invidious/routes/**" require "./invidious/jobs/**" +require "./invidious/migrations/*" CONFIG = Config.load HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) @@ -111,6 +112,8 @@ end OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mode: "a") LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) +# Run migrations +Invidious::Migrator.new(PG_DB).migrate # Check table integrity Invidious::Database.check_integrity(CONFIG) diff --git a/src/invidious/migration.cr b/src/invidious/migration.cr new file mode 100644 index 00000000..a4eec1c5 --- /dev/null +++ b/src/invidious/migration.cr @@ -0,0 +1,38 @@ +abstract class Invidious::Migration + macro inherited + Invidious::Migrator.migrations << self + end + + @@version : Int64? + + def self.version(version : Int32 | Int64) + @@version = version.to_i64 + end + + getter? completed = false + + def initialize(@db : DB::Database) + end + + abstract def up(conn : DB::Connection) + + def migrate + # migrator already ignores completed migrations + # but this is an extra check to make sure a migration doesn't run twice + return if completed? + + @db.transaction do |txn| + up(txn.connection) + track(txn.connection) + @completed = true + end + end + + def version : Int64 + @@version.not_nil! + end + + private def track(conn : DB::Connection) + conn.exec("INSERT INTO #{Invidious::Migrator::MIGRATIONS_TABLE}(version) VALUES ($1)", version) + end +end diff --git a/src/invidious/migrations/0000_create_channels_table.cr b/src/invidious/migrations/0000_create_channels_table.cr new file mode 100644 index 00000000..1f8f18e2 --- /dev/null +++ b/src/invidious/migrations/0000_create_channels_table.cr @@ -0,0 +1,30 @@ +module Invidious::Migrations + class CreateChannelsTable < Migration + version 0 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.channels + ( + id text NOT NULL, + author text, + updated timestamp with time zone, + deleted boolean, + subscribed timestamp with time zone, + CONSTRAINT channels_id_key UNIQUE (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.channels TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS channels_id_idx + ON public.channels + USING btree + (id COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/migrations/0001_create_videos_table.cr b/src/invidious/migrations/0001_create_videos_table.cr new file mode 100644 index 00000000..cdc9993f --- /dev/null +++ b/src/invidious/migrations/0001_create_videos_table.cr @@ -0,0 +1,28 @@ +module Invidious::Migrations + class CreateVideosTable < Migration + version 1 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE UNLOGGED TABLE IF NOT EXISTS public.videos + ( + id text NOT NULL, + info text, + updated timestamp with time zone, + CONSTRAINT videos_pkey PRIMARY KEY (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.videos TO current_user; + SQL + + conn.exec <<-SQL + CREATE UNIQUE INDEX IF NOT EXISTS id_idx + ON public.videos + USING btree + (id COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/migrations/0002_create_channel_videos_table.cr b/src/invidious/migrations/0002_create_channel_videos_table.cr new file mode 100644 index 00000000..737abad4 --- /dev/null +++ b/src/invidious/migrations/0002_create_channel_videos_table.cr @@ -0,0 +1,35 @@ +module Invidious::Migrations + class CreateChannelVideosTable < Migration + version 2 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.channel_videos + ( + id text NOT NULL, + title text, + published timestamp with time zone, + updated timestamp with time zone, + ucid text, + author text, + length_seconds integer, + live_now boolean, + premiere_timestamp timestamp with time zone, + views bigint, + CONSTRAINT channel_videos_id_key UNIQUE (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.channel_videos TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx + ON public.channel_videos + USING btree + (ucid COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/migrations/0003_create_users_table.cr b/src/invidious/migrations/0003_create_users_table.cr new file mode 100644 index 00000000..d91cca8d --- /dev/null +++ b/src/invidious/migrations/0003_create_users_table.cr @@ -0,0 +1,34 @@ +module Invidious::Migrations + class CreateUsersTable < Migration + version 3 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.users + ( + updated timestamp with time zone, + notifications text[], + subscriptions text[], + email text NOT NULL, + preferences text, + password text, + token text, + watched text[], + feed_needs_update boolean, + CONSTRAINT users_email_key UNIQUE (email) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.users TO current_user; + SQL + + conn.exec <<-SQL + CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx + ON public.users + USING btree + (lower(email) COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/migrations/0004_create_session_ids_table.cr b/src/invidious/migrations/0004_create_session_ids_table.cr new file mode 100644 index 00000000..9ef00f78 --- /dev/null +++ b/src/invidious/migrations/0004_create_session_ids_table.cr @@ -0,0 +1,28 @@ +module Invidious::Migrations + class CreateSessionIdsTable < Migration + version 4 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.session_ids + ( + id text NOT NULL, + email text, + issued timestamp with time zone, + CONSTRAINT session_ids_pkey PRIMARY KEY (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.session_ids TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS session_ids_id_idx + ON public.session_ids + USING btree + (id COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/migrations/0005_create_nonces_table.cr b/src/invidious/migrations/0005_create_nonces_table.cr new file mode 100644 index 00000000..4b1220e6 --- /dev/null +++ b/src/invidious/migrations/0005_create_nonces_table.cr @@ -0,0 +1,27 @@ +module Invidious::Migrations + class CreateNoncesTable < Migration + version 5 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.nonces + ( + nonce text, + expire timestamp with time zone, + CONSTRAINT nonces_id_key UNIQUE (nonce) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.nonces TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS nonces_nonce_idx + ON public.nonces + USING btree + (nonce COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/migrations/0006_create_annotations_table.cr b/src/invidious/migrations/0006_create_annotations_table.cr new file mode 100644 index 00000000..86f21dd9 --- /dev/null +++ b/src/invidious/migrations/0006_create_annotations_table.cr @@ -0,0 +1,20 @@ +module Invidious::Migrations + class CreateAnnotationsTable < Migration + version 6 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.annotations + ( + id text NOT NULL, + annotations xml, + CONSTRAINT annotations_id_key UNIQUE (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.annotations TO current_user; + SQL + end + end +end diff --git a/src/invidious/migrations/0007_create_playlists_table.cr b/src/invidious/migrations/0007_create_playlists_table.cr new file mode 100644 index 00000000..81217365 --- /dev/null +++ b/src/invidious/migrations/0007_create_playlists_table.cr @@ -0,0 +1,47 @@ +module Invidious::Migrations + class CreatePlaylistsTable < Migration + version 7 + + def up(conn : DB::Connection) + conn.exec <<-SQL + DO + $$ + BEGIN + IF NOT EXISTS (SELECT * + FROM pg_type typ + INNER JOIN pg_namespace nsp ON nsp.oid = typ.typnamespace + WHERE nsp.nspname = 'public' + AND typ.typname = 'privacy') THEN + CREATE TYPE public.privacy AS ENUM + ( + 'Public', + 'Unlisted', + 'Private' + ); + END IF; + END; + $$ + LANGUAGE plpgsql; + SQL + + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.playlists + ( + title text, + id text primary key, + author text, + description text, + video_count integer, + created timestamptz, + updated timestamptz, + privacy privacy, + index int8[] + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON public.playlists TO current_user; + SQL + end + end +end diff --git a/src/invidious/migrations/0008_create_playlist_videos_table.cr b/src/invidious/migrations/0008_create_playlist_videos_table.cr new file mode 100644 index 00000000..80fa6b5f --- /dev/null +++ b/src/invidious/migrations/0008_create_playlist_videos_table.cr @@ -0,0 +1,27 @@ +module Invidious::Migrations + class CreatePlaylistVideosTable < Migration + version 8 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.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) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.playlist_videos TO current_user; + SQL + end + end +end diff --git a/src/invidious/migrator.cr b/src/invidious/migrator.cr new file mode 100644 index 00000000..dc6880b9 --- /dev/null +++ b/src/invidious/migrator.cr @@ -0,0 +1,41 @@ +class Invidious::Migrator + MIGRATIONS_TABLE = "invidious_migrations" + + class_getter migrations = [] of Invidious::Migration.class + + def initialize(@db : DB::Database) + end + + def migrate + run_migrations = load_run_migrations + migrations = load_migrations.sort_by(&.version) + migrations_to_run = migrations.reject { |migration| run_migrations.includes?(migration.version) } + if migrations.empty? + puts "No migrations to run." + return + end + + migrations_to_run.each do |migration| + puts "Running migration: #{migration.class.name}" + migration.migrate + end + end + + private def load_migrations : Array(Invidious::Migration) + self.class.migrations.map(&.new(@db)) + end + + private def load_run_migrations : Array(Int64) + create_migrations_table + @db.query_all("SELECT version FROM #{MIGRATIONS_TABLE}", as: Int64) + end + + private def create_migrations_table + @db.exec <<-SQL + CREATE TABLE IF NOT EXISTS #{MIGRATIONS_TABLE} ( + id bigserial PRIMARY KEY, + version bigint NOT NULL + ) + SQL + end +end -- cgit v1.2.3 From cdd473e1951a219f95e145e0bc540c0e1882813e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 10 Feb 2022 19:52:45 +0100 Subject: DB: fix inverted arguments in User.update_password() Closes https://github.com/iv-org/invidious/issues/2875 --- src/invidious/database/users.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr index 26be4270..f62b43ea 100644 --- a/src/invidious/database/users.cr +++ b/src/invidious/database/users.cr @@ -171,7 +171,7 @@ module Invidious::Database::Users WHERE email = $2 SQL - PG_DB.exec(request, user.email, pass) + PG_DB.exec(request, pass, user.email) end # ------------------- -- cgit v1.2.3 From 01135db80a0272b3a6b0bc733b883d90ac414337 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 11 Feb 2022 01:36:53 +0100 Subject: video_playback: Check "host" parameter validity --- src/invidious/routes/video_playback.cr | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index f6340c57..6ac1e780 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -14,12 +14,18 @@ module Invidious::Routes::VideoPlayback end if query_params["host"]? && !query_params["host"].empty? - host = "https://#{query_params["host"]}" + host = query_params["host"] query_params.delete("host") else - host = "https://r#{fvip}---#{mns.pop}.googlevideo.com" + host = "r#{fvip}---#{mns.pop}.googlevideo.com" end + # Sanity check, to avoid being used as an open proxy + if !host.matches?(/[\w-]+.googlevideo.com/) + return error_template(400, "Invalid \"host\" parameter.") + end + + host = "https://#{host}" url = "/videoplayback?#{query_params}" headers = HTTP::Headers.new -- cgit v1.2.3 From ddf1e84f7c088051a20461f5c0b7fef069089c74 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Thu, 10 Feb 2022 23:43:14 -0600 Subject: Raise exception if playability not ok, also handle missing related videos --- src/invidious/videos.cr | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 335f6b60..81fce5b8 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -881,11 +881,13 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) - if player_response["playabilityStatus"]?.try &.["status"]?.try &.as_s != "OK" - reason = player_response["playabilityStatus"]["errorScreen"]?.try &.["playerErrorMessageRenderer"]?.try &.["subreason"]?.try { |s| - s["simpleText"]?.try &.as_s || s["runs"].as_a.map { |r| r["text"] }.join("") - } || player_response["playabilityStatus"]["reason"].as_s + if player_response.dig?("playabilityStatus", "status").try &.as_s != "OK" + subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason") + reason = subreason.try &.[]?("simpleText").try &.as_s + reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") + reason ||= player_response.dig("playabilityStatus", "reason").as_s params["reason"] = JSON::Any.new(reason) + return params end params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil) @@ -928,11 +930,8 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results primary_results = main_results.dig?("results", "results", "contents") - secondary_results = main_results - .dig?("secondaryResults", "secondaryResults", "results") raise BrokenTubeException.new("results") if !primary_results - raise BrokenTubeException.new("secondaryResults") if !secondary_results video_primary_renderer = primary_results .as_a.find(&.["videoPrimaryInfoRenderer"]?) @@ -952,7 +951,9 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ related = [] of JSON::Any # Parse "compactVideoRenderer" items (under secondary results) - secondary_results.as_a.each do |element| + secondary_results = main_results + .dig?("secondaryResults", "secondaryResults", "results") + secondary_results.try &.as_a.each do |element| if item = element["compactVideoRenderer"]? related_video = parse_related_video(item) related << JSON::Any.new(related_video) if related_video @@ -1119,7 +1120,9 @@ def fetch_video(id, region) info = embed_info if !embed_info["reason"]? end - raise InfoException.new(info["reason"]?.try &.as_s || "") if !info["videoDetails"]? + if reason = info["reason"]? + raise InfoException.new(reason.as_s || "") + end video = Video.new({ id: id, -- cgit v1.2.3 From cf13c11236de6c4e373b110730530c8e1f305900 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Thu, 10 Feb 2022 22:16:40 -0600 Subject: Migrations tweaks --- src/invidious.cr | 4 +- src/invidious/database/migration.cr | 38 ++++++++++++++++ .../migrations/0001_create_channels_table.cr | 30 +++++++++++++ .../migrations/0002_create_videos_table.cr | 28 ++++++++++++ .../migrations/0003_create_channel_videos_table.cr | 35 +++++++++++++++ .../database/migrations/0004_create_users_table.cr | 34 +++++++++++++++ .../migrations/0005_create_session_ids_table.cr | 28 ++++++++++++ .../migrations/0006_create_nonces_table.cr | 27 ++++++++++++ .../migrations/0007_create_annotations_table.cr | 20 +++++++++ .../migrations/0008_create_playlists_table.cr | 50 ++++++++++++++++++++++ .../0009_create_playlist_videos_table.cr | 27 ++++++++++++ .../migrations/0010_make_videos_unlogged.cr | 11 +++++ src/invidious/database/migrator.cr | 42 ++++++++++++++++++ src/invidious/migration.cr | 38 ---------------- .../migrations/0000_create_channels_table.cr | 30 ------------- .../migrations/0001_create_videos_table.cr | 28 ------------ .../migrations/0002_create_channel_videos_table.cr | 35 --------------- .../migrations/0003_create_users_table.cr | 34 --------------- .../migrations/0004_create_session_ids_table.cr | 28 ------------ .../migrations/0005_create_nonces_table.cr | 27 ------------ .../migrations/0006_create_annotations_table.cr | 20 --------- .../migrations/0007_create_playlists_table.cr | 47 -------------------- .../0008_create_playlist_videos_table.cr | 27 ------------ src/invidious/migrator.cr | 41 ------------------ 24 files changed, 372 insertions(+), 357 deletions(-) create mode 100644 src/invidious/database/migration.cr create mode 100644 src/invidious/database/migrations/0001_create_channels_table.cr create mode 100644 src/invidious/database/migrations/0002_create_videos_table.cr create mode 100644 src/invidious/database/migrations/0003_create_channel_videos_table.cr create mode 100644 src/invidious/database/migrations/0004_create_users_table.cr create mode 100644 src/invidious/database/migrations/0005_create_session_ids_table.cr create mode 100644 src/invidious/database/migrations/0006_create_nonces_table.cr create mode 100644 src/invidious/database/migrations/0007_create_annotations_table.cr create mode 100644 src/invidious/database/migrations/0008_create_playlists_table.cr create mode 100644 src/invidious/database/migrations/0009_create_playlist_videos_table.cr create mode 100644 src/invidious/database/migrations/0010_make_videos_unlogged.cr create mode 100644 src/invidious/database/migrator.cr delete mode 100644 src/invidious/migration.cr delete mode 100644 src/invidious/migrations/0000_create_channels_table.cr delete mode 100644 src/invidious/migrations/0001_create_videos_table.cr delete mode 100644 src/invidious/migrations/0002_create_channel_videos_table.cr delete mode 100644 src/invidious/migrations/0003_create_users_table.cr delete mode 100644 src/invidious/migrations/0004_create_session_ids_table.cr delete mode 100644 src/invidious/migrations/0005_create_nonces_table.cr delete mode 100644 src/invidious/migrations/0006_create_annotations_table.cr delete mode 100644 src/invidious/migrations/0007_create_playlists_table.cr delete mode 100644 src/invidious/migrations/0008_create_playlist_videos_table.cr delete mode 100644 src/invidious/migrator.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 6ec5f3a5..e8ad03ef 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -27,6 +27,7 @@ require "compress/zip" require "protodec/utils" require "./invidious/database/*" +require "./invidious/database/migrations/*" require "./invidious/helpers/*" require "./invidious/yt_backend/*" require "./invidious/*" @@ -34,7 +35,6 @@ require "./invidious/channels/*" require "./invidious/user/*" require "./invidious/routes/**" require "./invidious/jobs/**" -require "./invidious/migrations/*" CONFIG = Config.load HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) @@ -113,7 +113,7 @@ OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mo LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) # Run migrations -Invidious::Migrator.new(PG_DB).migrate +Invidious::Database::Migrator.new(PG_DB).migrate # Check table integrity Invidious::Database.check_integrity(CONFIG) diff --git a/src/invidious/database/migration.cr b/src/invidious/database/migration.cr new file mode 100644 index 00000000..921d8f38 --- /dev/null +++ b/src/invidious/database/migration.cr @@ -0,0 +1,38 @@ +abstract class Invidious::Database::Migration + macro inherited + Migrator.migrations << self + end + + @@version : Int64? + + def self.version(version : Int32 | Int64) + @@version = version.to_i64 + end + + getter? completed = false + + def initialize(@db : DB::Database) + end + + abstract def up(conn : DB::Connection) + + def migrate + # migrator already ignores completed migrations + # but this is an extra check to make sure a migration doesn't run twice + return if completed? + + @db.transaction do |txn| + up(txn.connection) + track(txn.connection) + @completed = true + end + end + + def version : Int64 + @@version.not_nil! + end + + private def track(conn : DB::Connection) + conn.exec("INSERT INTO #{Migrator::MIGRATIONS_TABLE} (version) VALUES ($1)", version) + end +end diff --git a/src/invidious/database/migrations/0001_create_channels_table.cr b/src/invidious/database/migrations/0001_create_channels_table.cr new file mode 100644 index 00000000..a1362bcf --- /dev/null +++ b/src/invidious/database/migrations/0001_create_channels_table.cr @@ -0,0 +1,30 @@ +module Invidious::Database::Migrations + class CreateChannelsTable < Migration + version 1 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.channels + ( + id text NOT NULL, + author text, + updated timestamp with time zone, + deleted boolean, + subscribed timestamp with time zone, + CONSTRAINT channels_id_key UNIQUE (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.channels TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS channels_id_idx + ON public.channels + USING btree + (id COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0002_create_videos_table.cr b/src/invidious/database/migrations/0002_create_videos_table.cr new file mode 100644 index 00000000..c2ac84f8 --- /dev/null +++ b/src/invidious/database/migrations/0002_create_videos_table.cr @@ -0,0 +1,28 @@ +module Invidious::Database::Migrations + class CreateVideosTable < Migration + version 2 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE UNLOGGED TABLE IF NOT EXISTS public.videos + ( + id text NOT NULL, + info text, + updated timestamp with time zone, + CONSTRAINT videos_pkey PRIMARY KEY (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.videos TO current_user; + SQL + + conn.exec <<-SQL + CREATE UNIQUE INDEX IF NOT EXISTS id_idx + ON public.videos + USING btree + (id COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0003_create_channel_videos_table.cr b/src/invidious/database/migrations/0003_create_channel_videos_table.cr new file mode 100644 index 00000000..c9b62e4c --- /dev/null +++ b/src/invidious/database/migrations/0003_create_channel_videos_table.cr @@ -0,0 +1,35 @@ +module Invidious::Database::Migrations + class CreateChannelVideosTable < Migration + version 3 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.channel_videos + ( + id text NOT NULL, + title text, + published timestamp with time zone, + updated timestamp with time zone, + ucid text, + author text, + length_seconds integer, + live_now boolean, + premiere_timestamp timestamp with time zone, + views bigint, + CONSTRAINT channel_videos_id_key UNIQUE (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.channel_videos TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx + ON public.channel_videos + USING btree + (ucid COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0004_create_users_table.cr b/src/invidious/database/migrations/0004_create_users_table.cr new file mode 100644 index 00000000..a13ba15f --- /dev/null +++ b/src/invidious/database/migrations/0004_create_users_table.cr @@ -0,0 +1,34 @@ +module Invidious::Database::Migrations + class CreateUsersTable < Migration + version 4 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.users + ( + updated timestamp with time zone, + notifications text[], + subscriptions text[], + email text NOT NULL, + preferences text, + password text, + token text, + watched text[], + feed_needs_update boolean, + CONSTRAINT users_email_key UNIQUE (email) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.users TO current_user; + SQL + + conn.exec <<-SQL + CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx + ON public.users + USING btree + (lower(email) COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0005_create_session_ids_table.cr b/src/invidious/database/migrations/0005_create_session_ids_table.cr new file mode 100644 index 00000000..13c2228d --- /dev/null +++ b/src/invidious/database/migrations/0005_create_session_ids_table.cr @@ -0,0 +1,28 @@ +module Invidious::Database::Migrations + class CreateSessionIdsTable < Migration + version 5 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.session_ids + ( + id text NOT NULL, + email text, + issued timestamp with time zone, + CONSTRAINT session_ids_pkey PRIMARY KEY (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.session_ids TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS session_ids_id_idx + ON public.session_ids + USING btree + (id COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0006_create_nonces_table.cr b/src/invidious/database/migrations/0006_create_nonces_table.cr new file mode 100644 index 00000000..cf1229e1 --- /dev/null +++ b/src/invidious/database/migrations/0006_create_nonces_table.cr @@ -0,0 +1,27 @@ +module Invidious::Database::Migrations + class CreateNoncesTable < Migration + version 6 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.nonces + ( + nonce text, + expire timestamp with time zone, + CONSTRAINT nonces_id_key UNIQUE (nonce) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.nonces TO current_user; + SQL + + conn.exec <<-SQL + CREATE INDEX IF NOT EXISTS nonces_nonce_idx + ON public.nonces + USING btree + (nonce COLLATE pg_catalog."default"); + SQL + end + end +end diff --git a/src/invidious/database/migrations/0007_create_annotations_table.cr b/src/invidious/database/migrations/0007_create_annotations_table.cr new file mode 100644 index 00000000..dcecbc3b --- /dev/null +++ b/src/invidious/database/migrations/0007_create_annotations_table.cr @@ -0,0 +1,20 @@ +module Invidious::Database::Migrations + class CreateAnnotationsTable < Migration + version 7 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.annotations + ( + id text NOT NULL, + annotations xml, + CONSTRAINT annotations_id_key UNIQUE (id) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.annotations TO current_user; + SQL + end + end +end diff --git a/src/invidious/database/migrations/0008_create_playlists_table.cr b/src/invidious/database/migrations/0008_create_playlists_table.cr new file mode 100644 index 00000000..6aa16e1a --- /dev/null +++ b/src/invidious/database/migrations/0008_create_playlists_table.cr @@ -0,0 +1,50 @@ +module Invidious::Database::Migrations + class CreatePlaylistsTable < Migration + version 8 + + def up(conn : DB::Connection) + if !privacy_type_exists?(conn) + conn.exec <<-SQL + CREATE TYPE public.privacy AS ENUM + ( + 'Public', + 'Unlisted', + 'Private' + ); + SQL + end + + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.playlists + ( + title text, + id text primary key, + author text, + description text, + video_count integer, + created timestamptz, + updated timestamptz, + privacy privacy, + index int8[] + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON public.playlists TO current_user; + SQL + end + + private def privacy_type_exists?(conn : DB::Connection) : Bool + request = <<-SQL + SELECT 1 AS one + FROM pg_type + INNER JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace + WHERE pg_namespace.nspname = 'public' + AND pg_type.typname = 'privacy' + LIMIT 1; + SQL + + !conn.query_one?(request, as: Int32).nil? + end + end +end diff --git a/src/invidious/database/migrations/0009_create_playlist_videos_table.cr b/src/invidious/database/migrations/0009_create_playlist_videos_table.cr new file mode 100644 index 00000000..84938b9b --- /dev/null +++ b/src/invidious/database/migrations/0009_create_playlist_videos_table.cr @@ -0,0 +1,27 @@ +module Invidious::Database::Migrations + class CreatePlaylistVideosTable < Migration + version 9 + + def up(conn : DB::Connection) + conn.exec <<-SQL + CREATE TABLE IF NOT EXISTS public.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) + ); + SQL + + conn.exec <<-SQL + GRANT ALL ON TABLE public.playlist_videos TO current_user; + SQL + end + end +end diff --git a/src/invidious/database/migrations/0010_make_videos_unlogged.cr b/src/invidious/database/migrations/0010_make_videos_unlogged.cr new file mode 100644 index 00000000..f5d19683 --- /dev/null +++ b/src/invidious/database/migrations/0010_make_videos_unlogged.cr @@ -0,0 +1,11 @@ +module Invidious::Database::Migrations + class MakeVideosUnlogged < Migration + version 10 + + def up(conn : DB::Connection) + conn.exec <<-SQL + ALTER TABLE public.videos SET UNLOGGED; + SQL + end + end +end diff --git a/src/invidious/database/migrator.cr b/src/invidious/database/migrator.cr new file mode 100644 index 00000000..2cd869c9 --- /dev/null +++ b/src/invidious/database/migrator.cr @@ -0,0 +1,42 @@ +class Invidious::Database::Migrator + MIGRATIONS_TABLE = "public.invidious_migrations" + + class_getter migrations = [] of Invidious::Database::Migration.class + + def initialize(@db : DB::Database) + end + + def migrate + versions = load_versions + + ran_migration = false + load_migrations.sort_by(&.version) + .each do |migration| + next if versions.includes?(migration.version) + + puts "Running migration: #{migration.class.name}" + migration.migrate + ran_migration = true + end + + puts "No migrations to run." unless ran_migration + end + + private def load_migrations : Array(Invidious::Database::Migration) + self.class.migrations.map(&.new(@db)) + end + + private def load_versions : Array(Int64) + create_migrations_table + @db.query_all("SELECT version FROM #{MIGRATIONS_TABLE}", as: Int64) + end + + private def create_migrations_table + @db.exec <<-SQL + CREATE TABLE IF NOT EXISTS #{MIGRATIONS_TABLE} ( + id bigserial PRIMARY KEY, + version bigint NOT NULL + ) + SQL + end +end diff --git a/src/invidious/migration.cr b/src/invidious/migration.cr deleted file mode 100644 index a4eec1c5..00000000 --- a/src/invidious/migration.cr +++ /dev/null @@ -1,38 +0,0 @@ -abstract class Invidious::Migration - macro inherited - Invidious::Migrator.migrations << self - end - - @@version : Int64? - - def self.version(version : Int32 | Int64) - @@version = version.to_i64 - end - - getter? completed = false - - def initialize(@db : DB::Database) - end - - abstract def up(conn : DB::Connection) - - def migrate - # migrator already ignores completed migrations - # but this is an extra check to make sure a migration doesn't run twice - return if completed? - - @db.transaction do |txn| - up(txn.connection) - track(txn.connection) - @completed = true - end - end - - def version : Int64 - @@version.not_nil! - end - - private def track(conn : DB::Connection) - conn.exec("INSERT INTO #{Invidious::Migrator::MIGRATIONS_TABLE}(version) VALUES ($1)", version) - end -end diff --git a/src/invidious/migrations/0000_create_channels_table.cr b/src/invidious/migrations/0000_create_channels_table.cr deleted file mode 100644 index 1f8f18e2..00000000 --- a/src/invidious/migrations/0000_create_channels_table.cr +++ /dev/null @@ -1,30 +0,0 @@ -module Invidious::Migrations - class CreateChannelsTable < Migration - version 0 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.channels - ( - id text NOT NULL, - author text, - updated timestamp with time zone, - deleted boolean, - subscribed timestamp with time zone, - CONSTRAINT channels_id_key UNIQUE (id) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.channels TO current_user; - SQL - - conn.exec <<-SQL - CREATE INDEX IF NOT EXISTS channels_id_idx - ON public.channels - USING btree - (id COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/migrations/0001_create_videos_table.cr b/src/invidious/migrations/0001_create_videos_table.cr deleted file mode 100644 index cdc9993f..00000000 --- a/src/invidious/migrations/0001_create_videos_table.cr +++ /dev/null @@ -1,28 +0,0 @@ -module Invidious::Migrations - class CreateVideosTable < Migration - version 1 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE UNLOGGED TABLE IF NOT EXISTS public.videos - ( - id text NOT NULL, - info text, - updated timestamp with time zone, - CONSTRAINT videos_pkey PRIMARY KEY (id) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.videos TO current_user; - SQL - - conn.exec <<-SQL - CREATE UNIQUE INDEX IF NOT EXISTS id_idx - ON public.videos - USING btree - (id COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/migrations/0002_create_channel_videos_table.cr b/src/invidious/migrations/0002_create_channel_videos_table.cr deleted file mode 100644 index 737abad4..00000000 --- a/src/invidious/migrations/0002_create_channel_videos_table.cr +++ /dev/null @@ -1,35 +0,0 @@ -module Invidious::Migrations - class CreateChannelVideosTable < Migration - version 2 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.channel_videos - ( - id text NOT NULL, - title text, - published timestamp with time zone, - updated timestamp with time zone, - ucid text, - author text, - length_seconds integer, - live_now boolean, - premiere_timestamp timestamp with time zone, - views bigint, - CONSTRAINT channel_videos_id_key UNIQUE (id) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.channel_videos TO current_user; - SQL - - conn.exec <<-SQL - CREATE INDEX IF NOT EXISTS channel_videos_ucid_idx - ON public.channel_videos - USING btree - (ucid COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/migrations/0003_create_users_table.cr b/src/invidious/migrations/0003_create_users_table.cr deleted file mode 100644 index d91cca8d..00000000 --- a/src/invidious/migrations/0003_create_users_table.cr +++ /dev/null @@ -1,34 +0,0 @@ -module Invidious::Migrations - class CreateUsersTable < Migration - version 3 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.users - ( - updated timestamp with time zone, - notifications text[], - subscriptions text[], - email text NOT NULL, - preferences text, - password text, - token text, - watched text[], - feed_needs_update boolean, - CONSTRAINT users_email_key UNIQUE (email) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.users TO current_user; - SQL - - conn.exec <<-SQL - CREATE UNIQUE INDEX IF NOT EXISTS email_unique_idx - ON public.users - USING btree - (lower(email) COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/migrations/0004_create_session_ids_table.cr b/src/invidious/migrations/0004_create_session_ids_table.cr deleted file mode 100644 index 9ef00f78..00000000 --- a/src/invidious/migrations/0004_create_session_ids_table.cr +++ /dev/null @@ -1,28 +0,0 @@ -module Invidious::Migrations - class CreateSessionIdsTable < Migration - version 4 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.session_ids - ( - id text NOT NULL, - email text, - issued timestamp with time zone, - CONSTRAINT session_ids_pkey PRIMARY KEY (id) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.session_ids TO current_user; - SQL - - conn.exec <<-SQL - CREATE INDEX IF NOT EXISTS session_ids_id_idx - ON public.session_ids - USING btree - (id COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/migrations/0005_create_nonces_table.cr b/src/invidious/migrations/0005_create_nonces_table.cr deleted file mode 100644 index 4b1220e6..00000000 --- a/src/invidious/migrations/0005_create_nonces_table.cr +++ /dev/null @@ -1,27 +0,0 @@ -module Invidious::Migrations - class CreateNoncesTable < Migration - version 5 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.nonces - ( - nonce text, - expire timestamp with time zone, - CONSTRAINT nonces_id_key UNIQUE (nonce) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.nonces TO current_user; - SQL - - conn.exec <<-SQL - CREATE INDEX IF NOT EXISTS nonces_nonce_idx - ON public.nonces - USING btree - (nonce COLLATE pg_catalog."default"); - SQL - end - end -end diff --git a/src/invidious/migrations/0006_create_annotations_table.cr b/src/invidious/migrations/0006_create_annotations_table.cr deleted file mode 100644 index 86f21dd9..00000000 --- a/src/invidious/migrations/0006_create_annotations_table.cr +++ /dev/null @@ -1,20 +0,0 @@ -module Invidious::Migrations - class CreateAnnotationsTable < Migration - version 6 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.annotations - ( - id text NOT NULL, - annotations xml, - CONSTRAINT annotations_id_key UNIQUE (id) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.annotations TO current_user; - SQL - end - end -end diff --git a/src/invidious/migrations/0007_create_playlists_table.cr b/src/invidious/migrations/0007_create_playlists_table.cr deleted file mode 100644 index 81217365..00000000 --- a/src/invidious/migrations/0007_create_playlists_table.cr +++ /dev/null @@ -1,47 +0,0 @@ -module Invidious::Migrations - class CreatePlaylistsTable < Migration - version 7 - - def up(conn : DB::Connection) - conn.exec <<-SQL - DO - $$ - BEGIN - IF NOT EXISTS (SELECT * - FROM pg_type typ - INNER JOIN pg_namespace nsp ON nsp.oid = typ.typnamespace - WHERE nsp.nspname = 'public' - AND typ.typname = 'privacy') THEN - CREATE TYPE public.privacy AS ENUM - ( - 'Public', - 'Unlisted', - 'Private' - ); - END IF; - END; - $$ - LANGUAGE plpgsql; - SQL - - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.playlists - ( - title text, - id text primary key, - author text, - description text, - video_count integer, - created timestamptz, - updated timestamptz, - privacy privacy, - index int8[] - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON public.playlists TO current_user; - SQL - end - end -end diff --git a/src/invidious/migrations/0008_create_playlist_videos_table.cr b/src/invidious/migrations/0008_create_playlist_videos_table.cr deleted file mode 100644 index 80fa6b5f..00000000 --- a/src/invidious/migrations/0008_create_playlist_videos_table.cr +++ /dev/null @@ -1,27 +0,0 @@ -module Invidious::Migrations - class CreatePlaylistVideosTable < Migration - version 8 - - def up(conn : DB::Connection) - conn.exec <<-SQL - CREATE TABLE IF NOT EXISTS public.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) - ); - SQL - - conn.exec <<-SQL - GRANT ALL ON TABLE public.playlist_videos TO current_user; - SQL - end - end -end diff --git a/src/invidious/migrator.cr b/src/invidious/migrator.cr deleted file mode 100644 index dc6880b9..00000000 --- a/src/invidious/migrator.cr +++ /dev/null @@ -1,41 +0,0 @@ -class Invidious::Migrator - MIGRATIONS_TABLE = "invidious_migrations" - - class_getter migrations = [] of Invidious::Migration.class - - def initialize(@db : DB::Database) - end - - def migrate - run_migrations = load_run_migrations - migrations = load_migrations.sort_by(&.version) - migrations_to_run = migrations.reject { |migration| run_migrations.includes?(migration.version) } - if migrations.empty? - puts "No migrations to run." - return - end - - migrations_to_run.each do |migration| - puts "Running migration: #{migration.class.name}" - migration.migrate - end - end - - private def load_migrations : Array(Invidious::Migration) - self.class.migrations.map(&.new(@db)) - end - - private def load_run_migrations : Array(Int64) - create_migrations_table - @db.query_all("SELECT version FROM #{MIGRATIONS_TABLE}", as: Int64) - end - - private def create_migrations_table - @db.exec <<-SQL - CREATE TABLE IF NOT EXISTS #{MIGRATIONS_TABLE} ( - id bigserial PRIMARY KEY, - version bigint NOT NULL - ) - SQL - end -end -- cgit v1.2.3 From 59654289cb7c024d3cf7c9d00d38cf4453bc48df Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Fri, 11 Feb 2022 22:43:16 -0600 Subject: Run migrations through CLI instead of when app starts --- src/invidious.cr | 9 ++++++++- src/invidious/database/migrator.cr | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index e8ad03ef..25ee7c78 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -102,6 +102,10 @@ Kemal.config.extra_options do |parser| puts SOFTWARE.to_pretty_json exit end + parser.on("--migrate", "Run any migrations") do + Invidious::Database::Migrator.new(PG_DB).migrate + exit + end end Kemal::CLI.new ARGV @@ -113,7 +117,10 @@ OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mo LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) # Run migrations -Invidious::Database::Migrator.new(PG_DB).migrate +if Invidious::Database::Migrator.new(PG_DB).pending_migrations? + puts "There are pending migrations. Run `invidious --migrate` to apply the migrations." + exit 46 +end # Check table integrity Invidious::Database.check_integrity(CONFIG) diff --git a/src/invidious/database/migrator.cr b/src/invidious/database/migrator.cr index 2cd869c9..660c3203 100644 --- a/src/invidious/database/migrator.cr +++ b/src/invidious/database/migrator.cr @@ -22,6 +22,13 @@ class Invidious::Database::Migrator puts "No migrations to run." unless ran_migration end + def pending_migrations? : Bool + versions = load_versions + + load_migrations.sort_by(&.version) + .any? { |migration| !versions.includes?(migration.version) } + end + private def load_migrations : Array(Invidious::Database::Migration) self.class.migrations.map(&.new(@db)) end -- cgit v1.2.3 From bf054dfda5ac6d1d3c4ab40b44a3bbb45ca132a3 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Sat, 12 Feb 2022 09:20:43 -0600 Subject: Do not check for pending migrations on app start This is so that we don't break deploys with this PR. Instead we only ship the 'invidious --migrate' cli command and let people test that. Maybe even ship a new migration that wouldn't break apps that don't run the migrations. Then we roll out the functionality that requires migrations. --- src/invidious.cr | 5 ----- 1 file changed, 5 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 25ee7c78..04b18a65 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -116,11 +116,6 @@ end OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mode: "a") LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) -# Run migrations -if Invidious::Database::Migrator.new(PG_DB).pending_migrations? - puts "There are pending migrations. Run `invidious --migrate` to apply the migrations." - exit 46 -end # Check table integrity Invidious::Database.check_integrity(CONFIG) -- cgit v1.2.3 From 60e870b27783bdcdb07d26489b50d18a85c49eeb Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 12 Feb 2022 17:32:20 +0100 Subject: Fix OPML import --- src/invidious/user/imports.cr | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 7404cd97..61d10719 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -105,12 +105,25 @@ struct Invidious::User # Youtube # ------------------- + private def is_opml?(mimetype : String, extension : String) + opml_mimetypes = [ + "application/xml", + "text/xml", + "text/x-opml", + "text/x-opml+xml", + ] + + opml_extensions = ["xml", "opml"] + + return opml_mimetypes.any?(&.== mimetype) || opml_extensions.any?(&.== extension) + end + # Import subscribed channels from Youtube # Returns success status def from_youtube(user : User, body : String, filename : String, type : String) : Bool extension = filename.split(".").last - if extension == "xml" || type == "application/xml" || type == "text/xml" + if is_opml?(type, extension) subscriptions = XML.parse(body) user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] -- cgit v1.2.3 From 57353fe0c611f2065ccec8863d26ab586d72fdfb Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 13 Feb 2022 22:35:22 +0100 Subject: Fix Freetube subscriptions import --- src/invidious/user/imports.cr | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 61d10719..f8b9e4e4 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -152,9 +152,16 @@ struct Invidious::User # ------------------- def from_freetube(user : User, body : String) + # Legacy import? matches = body.scan(/"channelId":"(?[a-zA-Z0-9_-]{24})"/) + subs = matches.map(&.["channel_id"]) - user.subscriptions += matches.map(&.["channel_id"]) + if subs.empty? + data = JSON.parse(body)["subscriptions"] + subs = data.as_a.map(&.["id"].as_s) + end + + user.subscriptions += subs user.subscriptions.uniq! user.subscriptions = get_batch_channels(user.subscriptions) -- cgit v1.2.3 From c952754c8cdf7d0eb51e827e625b9872cf75fd12 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Sat, 12 Feb 2022 17:01:52 +0100 Subject: Add videojs-persist plugin --- assets/js/player.js | 3 +++ src/invidious/views/components/player_sources.ecr | 1 + videojs-dependencies.yml | 4 ++++ 3 files changed, 8 insertions(+) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index 81a27009..5880bedc 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -677,3 +677,6 @@ if (window.location.pathname.startsWith("/embed/")) { cb = player.getChild('ControlBar') cb.addChild(watch_on_invidious_button) }; + +// Add usage of videojs-persist +player.persist(); diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index 9af3899c..305464c8 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -11,6 +11,7 @@ + diff --git a/videojs-dependencies.yml b/videojs-dependencies.yml index 6de23d25..b9754e0e 100644 --- a/videojs-dependencies.yml +++ b/videojs-dependencies.yml @@ -25,6 +25,10 @@ videojs-overlay: version: 2.1.4 shasum: 5a103b25374dbb753eb87960d8360c2e8f39cc05 +videojs-persist: + version: 0.1.2 + shasum: 44da05aced1fbf15693a36b7cce3cc4a9960dabe + videojs-share: version: 3.2.1 shasum: 0a3024b981387b9d21c058c829760a72c14b8ceb -- cgit v1.2.3 From 7048193f00a80617093eb9d8e1bc4557159afd74 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Sat, 12 Feb 2022 22:07:41 +0100 Subject: Move store of modification in Cookie instead of localStorage --- assets/js/player.js | 56 +++++++++++++++++++---- src/invidious/user/cookies.cr | 2 +- src/invidious/views/components/player_sources.ecr | 1 - videojs-dependencies.yml | 4 -- 4 files changed, 48 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index 34f721b4..5498df48 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -176,17 +176,55 @@ if (video_data.params.video_start > 0 || video_data.params.video_end > 0) { player.currentTime(video_data.params.video_start); } -/* - If the video settings are default, we enable the management of the settings by - the videojs-persist module otherwise we apply the preferences. -*/ -if (video_data.params.volume == 100 && video_data.params.speed == "1.0") - player.persist(); -else { - player.volume(video_data.params.volume / 100); - player.playbackRate(video_data.params.speed); +player.volume(video_data.params.volume / 100); +player.playbackRate(video_data.params.speed); + +/** + * Method for get content of Cookie + * @param {String} name Name of cookie + * @returns cookieValue + */ +function getCookieValue(name) { + var value = document.cookie.split(";").filter(item => { + return item.includes(name + "="); + }); + return value != null && value.length >= 1 ? value[0].substring((name + "=").length, value[0].length) : null; +} + +/** + * Method for update Prefs cookie (Or create if missing) + * @param {number} newVolume New Volume defined (Null if unchanged) + * @param {number} newSpeed New Speed defined (Null if unchanged) + */ +function updateCookie(newVolume, newSpeed) { + var volumeValue = newVolume != null ? newVolume : video_data.params.volume; + var speedValue = newSpeed != null ? newSpeed : video_data.params.speed; + var cookieValue = getCookieValue('PREFS'); + if (cookieValue != null) { + var cookieJson = JSON.parse(decodeURIComponent(cookieValue)); + cookieJson.volume = volumeValue; + cookieJson.speed = speedValue; + document.cookie = document.cookie.replace(getCookieValue('PREFS'), encodeURIComponent(JSON.stringify(cookieJson))); + } else { + var date = new Date(); + //Set expiration in 2 year + date.setTime(date.getTime() + 63115200); + document.cookie = 'PREFS=' + + encodeURIComponent(JSON.stringify({ 'volume': volumeValue, 'speed': speedValue })) + + '; expires=' + date.toGMTString() + '; SameSite=Strict; path=/'; + } + video_data.params.volume = volumeValue; + video_data.params.speed = speedValue; } +player.on('ratechange', function () { + updateCookie(null, player.playbackRate()); +}); + +player.on('volumechange', function () { + updateCookie(Math.ceil(player.volume() * 100), null); +}); + player.on('waiting', function () { if (player.playbackRate() > 1 && player.liveTracker.isLive() && player.liveTracker.atLiveEdge()) { console.log('Player has caught up to source, resetting playbackRate.') diff --git a/src/invidious/user/cookies.cr b/src/invidious/user/cookies.cr index 99df1b07..367f700f 100644 --- a/src/invidious/user/cookies.cr +++ b/src/invidious/user/cookies.cr @@ -30,7 +30,7 @@ struct Invidious::User value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, secure: SECURE, - http_only: true + http_only: false ) end end diff --git a/src/invidious/views/components/player_sources.ecr b/src/invidious/views/components/player_sources.ecr index 305464c8..9af3899c 100644 --- a/src/invidious/views/components/player_sources.ecr +++ b/src/invidious/views/components/player_sources.ecr @@ -11,7 +11,6 @@ - diff --git a/videojs-dependencies.yml b/videojs-dependencies.yml index b9754e0e..6de23d25 100644 --- a/videojs-dependencies.yml +++ b/videojs-dependencies.yml @@ -25,10 +25,6 @@ videojs-overlay: version: 2.1.4 shasum: 5a103b25374dbb753eb87960d8360c2e8f39cc05 -videojs-persist: - version: 0.1.2 - shasum: 44da05aced1fbf15693a36b7cce3cc4a9960dabe - videojs-share: version: 3.2.1 shasum: 0a3024b981387b9d21c058c829760a72c14b8ceb -- cgit v1.2.3 From 7112f3579378808a709b389ed4549baf74f679b1 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 14 Feb 2022 21:54:26 +0100 Subject: comments: don't error out when video has no comments continuationItems is nil when video has no comments --- src/invidious/comments.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 65f4b135..ab9fcc8b 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -78,7 +78,8 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b when "RELOAD_CONTINUATION_SLOT_HEADER" header = item["reloadContinuationItemsCommand"]["continuationItems"][0] when "RELOAD_CONTINUATION_SLOT_BODY" - contents = item["reloadContinuationItemsCommand"]["continuationItems"] + # continuationItems is nil when video has no comments + contents = item["reloadContinuationItemsCommand"]["continuationItems"]? end elsif item["appendContinuationItemsAction"]? contents = item["appendContinuationItemsAction"]["continuationItems"] -- cgit v1.2.3 From dbba9d76872a463c8f2a60e42a973e9591c4fc1b Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Fri, 18 Feb 2022 17:23:16 +0100 Subject: Expose version in /api/v1/stats with statistic disabled --- docker-compose.yml | 1 + src/invidious/routes/api/v1/misc.cr | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/docker-compose.yml b/docker-compose.yml index c76c314c..1c853a08 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,6 +34,7 @@ services: dbname: invidious full_refresh: false https_only: false + statistics_enabled: false domain: healthcheck: test: wget -nv --tries=1 --spider http://127.0.0.1:3000/api/v1/comments/jNQXAC9IVRw || exit 1 diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index a1ce0cbc..844fedb8 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -4,10 +4,10 @@ module Invidious::Routes::API::V1::Misc env.response.content_type = "application/json" if !CONFIG.statistics_enabled - return error_json(400, "Statistics are not enabled.") + return {"software" => SOFTWARE}.to_json + else + return Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json end - - Invidious::Jobs::StatisticsRefreshJob::STATISTICS.to_json end # APIv1 currently uses the same logic for both -- cgit v1.2.3 From f75a81c9eeb792c0b99075bc47a1243a23b8700b Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Mon, 21 Feb 2022 10:53:20 +0100 Subject: Make configurable time between each RefreshChannelsJob --- config/config.example.yml | 8 ++++++ docker-compose.yml | 2 ++ src/invidious/config.cr | 12 +++++---- src/invidious/helpers/utils.cr | 40 +++++++++++++++++++++--------- src/invidious/jobs/refresh_channels_job.cr | 5 ++-- 5 files changed, 47 insertions(+), 20 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 59cb486b..475f2703 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -314,6 +314,14 @@ https_only: false ## channel_threads: 1 +## +## Time between channel_refresh +## +## Accepted values: a valid time interval (hours:min:seconds) +## Default: 00:30:00 +## +channel_refresh_time: 00:30:00 + ## ## Forcefully dump and re-download the entire list of uploaded ## videos when crawling channel (during subscriptions update). diff --git a/docker-compose.yml b/docker-compose.yml index c76c314c..964bb702 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,7 +23,9 @@ services: environment: # Adapted from ./config/config.yml INVIDIOUS_CONFIG: | + log_level: Info channel_threads: 1 + channel_refresh_time: 00:30:00 check_tables: true feed_threads: 1 db: diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 72e145da..150f8064 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -56,11 +56,13 @@ end class Config include YAML::Serializable - property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) - property feed_threads : Int32 = 1 # Number of threads to use for updating feeds - property output : String = "STDOUT" # Log file path or STDOUT - property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr - property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc) + property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) + @[YAML::Field(converter: TimeSpanConverter)] + property channel_refresh_time : Time::Span = 30.minutes # Time between channel_refresh + property feed_threads : Int32 = 1 # Number of threads to use for updating feeds + property output : String = "STDOUT" # Log file path or STDOUT + property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr + property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc) @[YAML::Field(converter: Preferences::URIConverter)] property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index a58a21b1..2702c5e9 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -18,23 +18,39 @@ def elapsed_text(elapsed) "#{(millis * 1000).round(2)}µs" end -def decode_length_seconds(string) - length_seconds = string.gsub(/[^0-9:]/, "") - return 0_i32 if length_seconds.empty? +module TimeSpanConverter + def self.to_yaml(value : Time::Span, yaml : YAML::Nodes::Builder) + return yaml.scalar recode_length_seconds(value.total_seconds.to_i32) + end - length_seconds = length_seconds.split(":").map { |x| x.to_i? || 0 } - length_seconds = [0] * (3 - length_seconds.size) + length_seconds + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Time::Span + if node.is_a?(YAML::Nodes::Scalar) + return decode_time_span(node.value) + else + node.raise "Expected scalar, not #{node.class}" + end + end +end - length_seconds = Time::Span.new( - hours: length_seconds[0], - minutes: length_seconds[1], - seconds: length_seconds[2] - ).total_seconds.to_i32 +def decode_time_span(string : String) : Time::Span + time_span = string.gsub(/[^0-9:]/, "") + return Time::Span.new(seconds: 0) if time_span.empty? + + time_span = time_span.split(":").map { |x| x.to_i? || 0 } + time_span = [0] * (3 - time_span.size) + time_span + + return Time::Span.new( + hours: time_span[0], + minutes: time_span[1], + seconds: time_span[2] + ) +end - return length_seconds +def decode_length_seconds(string : String) : Int32 + return decode_time_span(string).total_seconds.to_i32 end -def recode_length_seconds(time) +def recode_length_seconds(time : Int32) : String if time <= 0 return "" else diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 55fb8154..3e04d1cd 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -58,9 +58,8 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob end end - # TODO: make this configurable - LOGGER.debug("RefreshChannelsJob: Done, sleeping for thirty minutes") - sleep 30.minutes + LOGGER.debug("RefreshChannelsJob: Done, sleeping for #{CONFIG.channel_refresh_time}") + sleep CONFIG.channel_refresh_time Fiber.yield end end -- cgit v1.2.3 From 18197e7e3eca53176e71b1dfe82dd2de85df175c Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Mon, 21 Feb 2022 11:13:24 +0100 Subject: Lint description of channel_refresh_time --- config/config.example.yml | 2 +- src/invidious/config.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 475f2703..5d4cea28 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -315,7 +315,7 @@ https_only: false channel_threads: 1 ## -## Time between channel_refresh +## Time between two jobs for crawling videos from channels ## ## Accepted values: a valid time interval (hours:min:seconds) ## Default: 00:30:00 diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 150f8064..9ffd2cd8 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -58,7 +58,7 @@ class Config property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) @[YAML::Field(converter: TimeSpanConverter)] - property channel_refresh_time : Time::Span = 30.minutes # Time between channel_refresh + property channel_refresh_time : Time::Span = 30.minutes # Time between two jobs for crawling videos from channels property feed_threads : Int32 = 1 # Number of threads to use for updating feeds property output : String = "STDOUT" # Log file path or STDOUT property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr -- cgit v1.2.3 From fd55b08a1dd7b1592d4298d2dc3c1ce3d412b3d3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 22 Feb 2022 00:17:18 +0100 Subject: Add albanian to the locales list --- src/invidious/helpers/i18n.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 6571dbe6..39e183f2 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -30,6 +30,7 @@ LOCALES_LIST = { "pt-PT" => "Português de Portugal", # Portuguese (Portugal) "ro" => "Română", # Romanian "ru" => "русский", # Russian + "sq" => "Shqip", # Albanian "sr" => "srpski (latinica)", # Serbian (Latin) "sr_Cyrl" => "српски (ћирилица)", # Serbian (Cyrillic) "sv-SE" => "Svenska", # Swedish -- cgit v1.2.3 From dfab62ce48ff1d2227d77c2fc61f7a4ea8da1988 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Tue, 22 Feb 2022 00:46:26 +0100 Subject: Rename new property to channel_refresh_interval Follow indications : https://github.com/iv-org/invidious/pull/2915#discussion_r811373503 --- config/config.example.yml | 2 +- docker-compose.yml | 2 +- src/invidious/config.cr | 2 +- src/invidious/jobs/refresh_channels_job.cr | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 5d4cea28..9519f34a 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -320,7 +320,7 @@ channel_threads: 1 ## Accepted values: a valid time interval (hours:min:seconds) ## Default: 00:30:00 ## -channel_refresh_time: 00:30:00 +channel_refresh_interval: 00:30:00 ## ## Forcefully dump and re-download the entire list of uploaded diff --git a/docker-compose.yml b/docker-compose.yml index 964bb702..ab35a496 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: INVIDIOUS_CONFIG: | log_level: Info channel_threads: 1 - channel_refresh_time: 00:30:00 + channel_refresh_interval: 00:30:00 check_tables: true feed_threads: 1 db: diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 9ffd2cd8..fc24c1e7 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -58,7 +58,7 @@ class Config property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) @[YAML::Field(converter: TimeSpanConverter)] - property channel_refresh_time : Time::Span = 30.minutes # Time between two jobs for crawling videos from channels + property channel_refresh_interval : Time::Span = 30.minutes # Time between two jobs for crawling videos from channels property feed_threads : Int32 = 1 # Number of threads to use for updating feeds property output : String = "STDOUT" # Log file path or STDOUT property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 3e04d1cd..92681408 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -58,8 +58,8 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob end end - LOGGER.debug("RefreshChannelsJob: Done, sleeping for #{CONFIG.channel_refresh_time}") - sleep CONFIG.channel_refresh_time + LOGGER.debug("RefreshChannelsJob: Done, sleeping for #{CONFIG.channel_refresh_interval}") + sleep CONFIG.channel_refresh_interval Fiber.yield end end -- cgit v1.2.3 From 5d2f2690e287d4fc1275dd9d3dc283e607117c0e Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Tue, 22 Feb 2022 00:59:55 +0100 Subject: Lint config properties Follow lint indications : https://github.com/iv-org/invidious/pull/2915#discussion_r811375584 --- src/invidious/config.cr | 85 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 56 insertions(+), 29 deletions(-) (limited to 'src') diff --git a/src/invidious/config.cr b/src/invidious/config.cr index fc24c1e7..158a05cc 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -56,22 +56,35 @@ end class Config include YAML::Serializable - property channel_threads : Int32 = 1 # Number of threads to use for crawling videos from channels (for updating subscriptions) + # Number of threads to use for crawling videos from channels (for updating subscriptions) + property channel_threads : Int32 = 1 + # Time between two jobs for crawling videos from channels @[YAML::Field(converter: TimeSpanConverter)] - property channel_refresh_interval : Time::Span = 30.minutes # Time between two jobs for crawling videos from channels - property feed_threads : Int32 = 1 # Number of threads to use for updating feeds - property output : String = "STDOUT" # Log file path or STDOUT - property log_level : LogLevel = LogLevel::Info # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr - property db : DBConfig? = nil # Database configuration with separate parameters (username, hostname, etc) - + property channel_refresh_interval : Time::Span = 30.minutes + # Number of threads to use for updating feeds + property feed_threads : Int32 = 1 + # Log file path or STDOUT + property output : String = "STDOUT" + # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr + property log_level : LogLevel = LogLevel::Info + # Database configuration with separate parameters (username, hostname, etc) + property db : DBConfig? = nil + + # Database configuration using 12-Factor "Database URL" syntax @[YAML::Field(converter: Preferences::URIConverter)] - property database_url : URI = URI.parse("") # Database configuration using 12-Factor "Database URL" syntax - property decrypt_polling : Bool = true # Use polling to keep decryption function up to date - property full_refresh : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel - property https_only : Bool? # Used to tell Invidious it is behind a proxy, so links to resources should be https:// - property hmac_key : String? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions - property domain : String? # Domain to be used for links to resources on the site where an absolute URL is required - property use_pubsub_feeds : Bool | Int32 = false # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) + property database_url : URI = URI.parse("") + # Use polling to keep decryption function up to date + property decrypt_polling : Bool = true + # Used for crawling channels: threads should check all videos uploaded by a channel + property full_refresh : Bool = false + # Used to tell Invidious it is behind a proxy, so links to resources should be https:// + property https_only : Bool? + # HMAC signing key for CSRF tokens and verifying pubsub subscriptions + property hmac_key : String? + # Domain to be used for links to resources on the site where an absolute URL is required + property domain : String? + # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) + property use_pubsub_feeds : Bool | Int32 = false property popular_enabled : Bool = true property captcha_enabled : Bool = true property login_enabled : Bool = true @@ -80,28 +93,42 @@ class Config property admins : Array(String) = [] of String property external_port : Int32? = nil property default_user_preferences : ConfigPreferences = ConfigPreferences.from_yaml("") - property dmca_content : Array(String) = [] of String # For compliance with DMCA, disables download widget using list of video IDs - property check_tables : Bool = false # Check table integrity, automatically try to add any missing columns, create tables, etc. - property cache_annotations : Bool = false # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards - property banner : String? = nil # Optional banner to be displayed along top of page for announcements, etc. - property hsts : Bool? = true # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely - property disable_proxy : Bool? | Array(String)? = false # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' + # For compliance with DMCA, disables download widget using list of video IDs + property dmca_content : Array(String) = [] of String + # Check table integrity, automatically try to add any missing columns, create tables, etc. + property check_tables : Bool = false + # Cache annotations requested from IA, will not cache empty annotations or annotations that only contain cards + property cache_annotations : Bool = false + # Optional banner to be displayed along top of page for announcements, etc. + property banner : String? = nil + # Enables 'Strict-Transport-Security'. Ensure that `domain` and all subdomains are served securely + property hsts : Bool? = true + # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' + property disable_proxy : Bool? | Array(String)? = false # URL to the modified source code to be easily AGPL compliant # Will display in the footer, next to the main source code link property modified_source_code_url : String? = nil + # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) @[YAML::Field(converter: Preferences::FamilyConverter)] - property force_resolve : Socket::Family = Socket::Family::UNSPEC # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) - property port : Int32 = 3000 # Port to listen for connections (overridden by command line argument) - property host_binding : String = "0.0.0.0" # Host to bind (overridden by command line argument) - property pool_size : Int32 = 100 # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) - property use_quic : Bool = false # Use quic transport for youtube api - + property force_resolve : Socket::Family = Socket::Family::UNSPEC + # Port to listen for connections (overridden by command line argument) + property port : Int32 = 3000 + # Host to bind (overridden by command line argument) + property host_binding : String = "0.0.0.0" + # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) + property pool_size : Int32 = 100 + # Use quic transport for youtube api + property use_quic : Bool = false + + # Saved cookies in "name1=value1; name2=value2..." format @[YAML::Field(converter: Preferences::StringToCookies)] - property cookies : HTTP::Cookies = HTTP::Cookies.new # Saved cookies in "name1=value1; name2=value2..." format - property captcha_key : String? = nil # Key for Anti-Captcha - property captcha_api_url : String = "https://api.anti-captcha.com" # API URL for Anti-Captcha + property cookies : HTTP::Cookies = HTTP::Cookies.new + # Key for Anti-Captcha + property captcha_key : String? = nil + # API URL for Anti-Captcha + property captcha_api_url : String = "https://api.anti-captcha.com" def disabled?(option) case disabled = CONFIG.disable_proxy -- cgit v1.2.3 From f109d812a1d8ea92a4ebdeb7ce03ad93bcbd9a91 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Tue, 22 Feb 2022 01:34:19 +0100 Subject: Move TimeSpanConverter with another Converters Follow indications : https://github.com/iv-org/invidious/pull/2915#discussion_r811373953 --- src/invidious/config.cr | 2 +- src/invidious/helpers/utils.cr | 14 -------------- src/invidious/user/preferences.cr | 16 +++++++++++++++- 3 files changed, 16 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 158a05cc..cf705d21 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -59,7 +59,7 @@ class Config # Number of threads to use for crawling videos from channels (for updating subscriptions) property channel_threads : Int32 = 1 # Time between two jobs for crawling videos from channels - @[YAML::Field(converter: TimeSpanConverter)] + @[YAML::Field(converter: Preferences::TimeSpanConverter)] property channel_refresh_interval : Time::Span = 30.minutes # Number of threads to use for updating feeds property feed_threads : Int32 = 1 diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 2702c5e9..22575c57 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -18,20 +18,6 @@ def elapsed_text(elapsed) "#{(millis * 1000).round(2)}µs" end -module TimeSpanConverter - def self.to_yaml(value : Time::Span, yaml : YAML::Nodes::Builder) - return yaml.scalar recode_length_seconds(value.total_seconds.to_i32) - end - - def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Time::Span - if node.is_a?(YAML::Nodes::Scalar) - return decode_time_span(node.value) - else - node.raise "Expected scalar, not #{node.class}" - end - end -end - def decode_time_span(string : String) : Time::Span time_span = string.gsub(/[^0-9:]/, "") return Time::Span.new(seconds: 0) if time_span.empty? diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr index bf7ea401..c01bdd82 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -256,4 +256,18 @@ struct Preferences cookies end end -end + + module TimeSpanConverter + def self.to_yaml(value : Time::Span, yaml : YAML::Nodes::Builder) + return yaml.scalar recode_length_seconds(value.total_seconds.to_i32) + end + + def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Time::Span + if node.is_a?(YAML::Nodes::Scalar) + return decode_time_span(node.value) + else + node.raise "Expected scalar, not #{node.class}" + end + end + end +end \ No newline at end of file -- cgit v1.2.3 From fd0ac3a6719b3d6280ce2e784ad370c8d10a6129 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Tue, 22 Feb 2022 01:35:35 +0100 Subject: Update management of channel_refresh_interval Follow indications: https://github.com/iv-org/invidious/pull/2915#discussion_r811373503 --- config/config.example.yml | 6 +++--- docker-compose.yml | 2 +- src/invidious/helpers/utils.cr | 18 ++++++++++++++++++ src/invidious/user/preferences.cr | 6 +++--- 4 files changed, 25 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 9519f34a..a6440e3d 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -317,10 +317,10 @@ channel_threads: 1 ## ## Time between two jobs for crawling videos from channels ## -## Accepted values: a valid time interval (hours:min:seconds) -## Default: 00:30:00 +## Accepted values: a valid time interval (like 1h30m or 90min) +## Default: 30m ## -channel_refresh_interval: 00:30:00 +channel_refresh_interval: 30min ## ## Forcefully dump and re-download the entire list of uploaded diff --git a/docker-compose.yml b/docker-compose.yml index ab35a496..4fdad921 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,7 +25,7 @@ services: INVIDIOUS_CONFIG: | log_level: Info channel_threads: 1 - channel_refresh_interval: 00:30:00 + channel_refresh_interval: 30m check_tables: true feed_threads: 1 db: diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 22575c57..53f64f4e 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -53,6 +53,24 @@ def recode_length_seconds(time : Int32) : String end end +def decode_interval(string : String) : Time::Span + rawMinutes = string.try &.to_i32? + + if !rawMinutes + hours = /(?\d+)h/.match(string).try &.["hours"].try &.to_i32 + hours ||= 0 + + minutes = /(?\d+)m(?!s)/.match(string).try &.["minutes"].try &.to_i32 + minutes ||= 0 + + time = Time::Span.new(hours: hours, minutes: minutes) + else + time = Time::Span.new(minutes: rawMinutes) + end + + return time +end + def decode_time(string) time = string.try &.to_f? diff --git a/src/invidious/user/preferences.cr b/src/invidious/user/preferences.cr index c01bdd82..9eeed53f 100644 --- a/src/invidious/user/preferences.cr +++ b/src/invidious/user/preferences.cr @@ -259,15 +259,15 @@ struct Preferences module TimeSpanConverter def self.to_yaml(value : Time::Span, yaml : YAML::Nodes::Builder) - return yaml.scalar recode_length_seconds(value.total_seconds.to_i32) + return yaml.scalar value.total_minutes.to_i32 end def self.from_yaml(ctx : YAML::ParseContext, node : YAML::Nodes::Node) : Time::Span if node.is_a?(YAML::Nodes::Scalar) - return decode_time_span(node.value) + return decode_interval(node.value) else node.raise "Expected scalar, not #{node.class}" end end end -end \ No newline at end of file +end -- cgit v1.2.3 From 555bb711c9bb16f8a78a8b71146367438b81918e Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Tue, 22 Feb 2022 08:17:50 +0100 Subject: Removal of changes to methods now unrelated to the issue Unrelated to the issue since the change in management of channel_refresh_interval Cf this remark : https://github.com/iv-org/invidious/pull/2915#discussion_r811373503 --- src/invidious/helpers/utils.cr | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 53f64f4e..c1dc17db 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -18,25 +18,23 @@ def elapsed_text(elapsed) "#{(millis * 1000).round(2)}µs" end -def decode_time_span(string : String) : Time::Span - time_span = string.gsub(/[^0-9:]/, "") - return Time::Span.new(seconds: 0) if time_span.empty? - - time_span = time_span.split(":").map { |x| x.to_i? || 0 } - time_span = [0] * (3 - time_span.size) + time_span - - return Time::Span.new( - hours: time_span[0], - minutes: time_span[1], - seconds: time_span[2] - ) -end +def decode_length_seconds(string) + length_seconds = string.gsub(/[^0-9:]/, "") + return 0_i32 if length_seconds.empty? + + length_seconds = length_seconds.split(":").map { |x| x.to_i? || 0 } + length_seconds = [0] * (3 - length_seconds.size) + length_seconds + + length_seconds = Time::Span.new( + hours: length_seconds[0], + minutes: length_seconds[1], + seconds: length_seconds[2] + ).total_seconds.to_i32 -def decode_length_seconds(string : String) : Int32 - return decode_time_span(string).total_seconds.to_i32 + return length_seconds end -def recode_length_seconds(time : Int32) : String +def recode_length_seconds(time) if time <= 0 return "" else -- cgit v1.2.3 From fe057c78737458132248e39b7ee7572b67f26918 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 22 Feb 2022 17:42:41 +0100 Subject: Make a function that builds the download widget's HTML --- src/invidious.cr | 2 + src/invidious/frontend/watch_page.cr | 108 +++++++++++++++++++++++++++++++++++ src/invidious/routes/watch.cr | 8 +++ src/invidious/views/watch.ecr | 36 +----------- 4 files changed, 119 insertions(+), 35 deletions(-) create mode 100644 src/invidious/frontend/watch_page.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index d4878759..d742cd59 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -29,6 +29,8 @@ require "protodec/utils" require "./invidious/database/*" require "./invidious/helpers/*" require "./invidious/yt_backend/*" +require "./invidious/frontend/*" + require "./invidious/*" require "./invidious/channels/*" require "./invidious/user/*" diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr new file mode 100644 index 00000000..d3a50705 --- /dev/null +++ b/src/invidious/frontend/watch_page.cr @@ -0,0 +1,108 @@ +module Invidious::Frontend::WatchPage + extend self + + # A handy structure to pass many elements at + # once to the download widget function + struct VideoAssets + getter full_videos : Array(Hash(String, JSON::Any)) + getter video_streams : Array(Hash(String, JSON::Any)) + getter audio_streams : Array(Hash(String, JSON::Any)) + getter captions : Array(Caption) + + def initialize( + @full_videos, + @video_streams, + @audio_streams, + @captions + ) + end + end + + def download_widget(locale : String, video : Video, video_assets : VideoAssets) : String + if CONFIG.disabled?("downloads") + return "

    #{translate(locale, "Download is disabled.")}

    " + end + + return String.build(4000) do |str| + str << "" + str << '\n' + + str << "\t
    \n" + + str << "\t\t\n" + + # TODO: remove inline style + str << "\t\t\n" + str << "\t
    \n" + + str << "\t\n" + + str << "\n" + end + end +end diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 42bc4219..c34ce715 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -189,6 +189,14 @@ module Invidious::Routes::Watch return env.redirect url end + # Structure used for the download widget + video_assets = Invidious::Frontend::WatchPage::VideoAssets.new( + full_videos: fmt_stream, + video_streams: video_streams, + audio_streams: audio_streams, + captions: video.captions + ) + templated "watch" end diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 2e0aee99..0e4af3ab 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -168,41 +168,7 @@ we're going to need to do it here in order to allow for translations. <% end %> <% end %> - <% if CONFIG.dmca_content.includes?(video.id) || CONFIG.disabled?("downloads") %> -

    <%= translate(locale, "Download is disabled.") %>

    - <% else %> -
    -
    - - -
    - - -
    - <% end %> + <%= Invidious::Frontend::WatchPage.download_widget(locale, video, video_assets) %>

    <%= number_with_separator(video.views) %>

    <%= number_with_separator(video.likes) %>

    -- cgit v1.2.3 From 09a585c93bb28a49c9538b47803bb5341e9f928b Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Tue, 22 Feb 2022 18:57:21 +0100 Subject: Add sameSite policy in cookie management in server side --- src/invidious/user/cookies.cr | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/user/cookies.cr b/src/invidious/user/cookies.cr index 367f700f..65e079ec 100644 --- a/src/invidious/user/cookies.cr +++ b/src/invidious/user/cookies.cr @@ -17,7 +17,8 @@ struct Invidious::User value: sid, expires: Time.utc + 2.years, secure: SECURE, - http_only: true + http_only: true, + samesite: HTTP::Cookie::SameSite::Strict ) end @@ -30,7 +31,8 @@ struct Invidious::User value: URI.encode_www_form(preferences.to_json), expires: Time.utc + 2.years, secure: SECURE, - http_only: false + http_only: false, + samesite: HTTP::Cookie::SameSite::Strict ) end end -- cgit v1.2.3 From b58b0440d6bd314f1e9831f7c3924d8f6ea75b10 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 22 Feb 2022 19:44:41 +0100 Subject: Fix captions regex --- src/invidious/routes/api/v1/videos.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 2b23d2ad..2a4911db 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -136,7 +136,7 @@ module Invidious::Routes::API::V1::Videos # # See: https://github.com/iv-org/invidious/issues/2391 webvtt = YT_POOL.client &.get("#{url}&format=vtt").body - .gsub(/([0-9:.]+ --> [0-9:.]+).+/, "\\1") + .gsub(/([0-9:.]{12} --> [0-9:.]{12}).+/, "\\1") end if title = env.params.query["title"]? -- cgit v1.2.3 From e60a1836fe077fa00fbfc8e8f9e4401b7151192c Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Tue, 22 Feb 2022 23:19:59 +0100 Subject: Lint config.example.yml and config.cr Follow lint indications : - https://github.com/iv-org/invidious/pull/2915#discussion_r812396203 - https://github.com/iv-org/invidious/pull/2915#discussion_r812396807 --- config/config.example.yml | 7 ++++--- src/invidious/config.cr | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index a6440e3d..866e8944 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -315,12 +315,13 @@ https_only: false channel_threads: 1 ## -## Time between two jobs for crawling videos from channels +## Time interval between two executions of the job that crawls +## channel videos (subscriptions update). ## -## Accepted values: a valid time interval (like 1h30m or 90min) +## Accepted values: a valid time interval (like 1h30m or 90m) ## Default: 30m ## -channel_refresh_interval: 30min +#channel_refresh_interval: 30m ## ## Forcefully dump and re-download the entire list of uploaded diff --git a/src/invidious/config.cr b/src/invidious/config.cr index cf705d21..bebb9ae5 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -58,7 +58,7 @@ class Config # Number of threads to use for crawling videos from channels (for updating subscriptions) property channel_threads : Int32 = 1 - # Time between two jobs for crawling videos from channels + # Time interval between two executions of the job that crawls channel videos (subscriptions update). @[YAML::Field(converter: Preferences::TimeSpanConverter)] property channel_refresh_interval : Time::Span = 30.minutes # Number of threads to use for updating feeds -- cgit v1.2.3 From cc59de0c9310b31b002e836ffe055dfaf5cfafd7 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Tue, 22 Feb 2022 23:04:30 -0600 Subject: Extract live endpoints to route --- src/invidious.cr | 41 ++++------------------------------------- src/invidious/routes/live.cr | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 37 deletions(-) create mode 100644 src/invidious/routes/live.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index d4878759..24f49930 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -399,6 +399,10 @@ Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails +Invidious::Routing.get "/channel/:ucid/live", Invidious::Routes::Live, :check +Invidious::Routing.get "/user/:user/live", Invidious::Routes::Live, :check +Invidious::Routing.get "/c/:user/live", Invidious::Routes::Live, :check + # API routes (macro) define_v1_api_routes() @@ -406,43 +410,6 @@ define_v1_api_routes() define_api_manifest_routes() define_video_playback_routes() -# Channels - -{"/channel/:ucid/live", "/user/:user/live", "/c/:user/live"}.each do |route| - get route do |env| - locale = env.get("preferences").as(Preferences).locale - - # Appears to be a bug in routing, having several routes configured - # as `/a/:a`, `/b/:a`, `/c/:a` results in 404 - value = env.request.resource.split("/")[2] - body = "" - {"channel", "user", "c"}.each do |type| - response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1") - if response.status_code == 200 - body = response.body - end - end - - video_id = body.match(/'VIDEO_ID': "(?[a-zA-Z0-9_-]{11})"/).try &.["id"]? - if video_id - params = [] of String - env.params.query.each do |k, v| - params << "#{k}=#{v}" - end - params = params.join("&") - - url = "/watch?v=#{video_id}" - if !params.empty? - url += "&#{params}" - end - - env.redirect url - else - env.redirect "/channel/#{value}" - end - end -end - # Authenticated endpoints # The notification APIs can't be extracted yet diff --git a/src/invidious/routes/live.cr b/src/invidious/routes/live.cr new file mode 100644 index 00000000..e55111ce --- /dev/null +++ b/src/invidious/routes/live.cr @@ -0,0 +1,34 @@ +module Invidious::Routes::Live + def self.check(env) + locale = env.get("preferences").as(Preferences).locale + + # Appears to be a bug in routing, having several routes configured + # as `/a/:a`, `/b/:a`, `/c/:a` results in 404 + value = env.request.resource.split("/")[2] + body = "" + {"channel", "user", "c"}.each do |type| + response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1") + if response.status_code == 200 + body = response.body + end + end + + video_id = body.match(/'VIDEO_ID': "(?[a-zA-Z0-9_-]{11})"/).try &.["id"]? + if video_id + params = [] of String + env.params.query.each do |k, v| + params << "#{k}=#{v}" + end + params = params.join("&") + + url = "/watch?v=#{video_id}" + if !params.empty? + url += "&#{params}" + end + + env.redirect url + else + env.redirect "/channel/#{value}" + end + end +end -- cgit v1.2.3 From 3b1837a99b7abfcc3950605fa7e99f7e0c92ba4d Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Tue, 22 Feb 2022 23:20:09 -0600 Subject: Move remaining routes to new structure --- src/invidious.cr | 51 ++++------------------------ src/invidious/routes/api/v1/authenticated.cr | 18 ++++++++++ src/invidious/routes/captcha.cr | 8 +++++ src/invidious/routes/playlists.cr | 11 ++++++ 4 files changed, 44 insertions(+), 44 deletions(-) create mode 100644 src/invidious/routes/captcha.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 24f49930..dc055e59 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -154,8 +154,8 @@ if CONFIG.popular_enabled Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) end -connection_channel = Channel({Bool, Channel(PQ::Notification)}).new(32) -Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(connection_channel, CONFIG.database_url) +CONNECTION_CHANNEL = Channel({Bool, Channel(PQ::Notification)}).new(32) +Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url) Invidious::Jobs.start_all @@ -360,6 +360,7 @@ end Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix + Invidious::Routing.get "/watch_videos", Invidious::Routes::Playlists, :watch_videos Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch Invidious::Routing.get "/results", Invidious::Routes::Search, :results @@ -390,6 +391,8 @@ end Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager + + Invidious::Routing.get "/Captcha", Invidious::Routes::Captcha, :get {% end %} Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht @@ -405,53 +408,13 @@ Invidious::Routing.get "/c/:user/live", Invidious::Routes::Live, :check # API routes (macro) define_v1_api_routes() +Invidious::Routing.get "/api/v1/auth/notifications", Invidious::Routes::API::V1::Authenticated, :notifications_get +Invidious::Routing.post "/api/v1/auth/notifications", Invidious::Routes::API::V1::Authenticated, :notifications_post # Video playback (macros) define_api_manifest_routes() define_video_playback_routes() -# Authenticated endpoints - -# The notification APIs can't be extracted yet -# due to the requirement of the `connection_channel` -# used by the `NotificationJob` - -get "/api/v1/auth/notifications" do |env| - env.response.content_type = "text/event-stream" - - topics = env.params.query["topics"]?.try &.split(",").uniq.first(1000) - topics ||= [] of String - - create_notification_stream(env, topics, connection_channel) -end - -post "/api/v1/auth/notifications" do |env| - env.response.content_type = "text/event-stream" - - topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000) - topics ||= [] of String - - create_notification_stream(env, topics, connection_channel) -end - -get "/Captcha" do |env| - headers = HTTP::Headers{":authority" => "accounts.google.com"} - response = YT_POOL.client &.get(env.request.resource, headers) - env.response.headers["Content-Type"] = response.headers["Content-Type"] - response.body -end - -# Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos -get "/watch_videos" do |env| - response = YT_POOL.client &.get(env.request.resource) - if url = response.headers["Location"]? - url = URI.parse(url).request_target - next env.redirect url - end - - env.response.status_code = response.status_code -end - error 404 do |env| if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) item = md["id"] diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index c27853ca..6ced4edb 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -397,4 +397,22 @@ module Invidious::Routes::API::V1::Authenticated env.response.status_code = 204 end + + def self.notifications_get(env) + env.response.content_type = "text/event-stream" + + topics = env.params.query["topics"]?.try &.split(",").uniq.first(1000) + topics ||= [] of String + + create_notification_stream(env, topics, CONNECTION_CHANNEL) + end + + def self.notifications_post(env) + env.response.content_type = "text/event-stream" + + topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000) + topics ||= [] of String + + create_notification_stream(env, topics, CONNECTION_CHANNEL) + end end diff --git a/src/invidious/routes/captcha.cr b/src/invidious/routes/captcha.cr new file mode 100644 index 00000000..a1d95a4f --- /dev/null +++ b/src/invidious/routes/captcha.cr @@ -0,0 +1,8 @@ +module Invidious::Routes::Captcha + def self.get(env) + headers = HTTP::Headers{":authority" => "accounts.google.com"} + response = YT_POOL.client &.get(env.request.resource, headers) + env.response.headers["Content-Type"] = response.headers["Content-Type"] + response.body + end +end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 1ed29e79..dbeb4f97 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -443,4 +443,15 @@ module Invidious::Routes::Playlists templated "mix" end + + # Undocumented, creates anonymous playlist with specified 'video_ids', max 50 videos + def self.watch_videos(env) + response = YT_POOL.client &.get(env.request.resource) + if url = response.headers["Location"]? + url = URI.parse(url).request_target + return env.redirect url + end + + env.response.status_code = response.status_code + end end -- cgit v1.2.3 From 2f335b3d2c2805d5de1b0204920c439b87f5646b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 22 Feb 2022 18:11:11 +0100 Subject: Use a dedicated endpoind for downloads This allows us to not pass file name ("title") in the form data and to enforce some sanity checks --- src/invidious.cr | 3 +++ src/invidious/frontend/watch_page.cr | 20 +++++++------- src/invidious/routes/api/v1/videos.cr | 6 ++++- src/invidious/routes/video_playback.cr | 36 ++++++++++++------------- src/invidious/routes/watch.cr | 48 ++++++++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 31 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index d742cd59..d1c3ac83 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -236,6 +236,7 @@ before_all do |env| "/api/manifest/", "/videoplayback", "/latest_version", + "/download", }.any? { |r| env.request.resource.starts_with? r } if env.request.cookies.has_key? "SID" @@ -348,6 +349,8 @@ end Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect + Invidious::Routing.post "/download", Invidious::Routes::Watch, :download + Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index d3a50705..80b67641 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -26,12 +26,16 @@ module Invidious::Frontend::WatchPage return String.build(4000) do |str| str << "" str << '\n' + # Hidden inputs for video id and title + str << "\n" + str << "\n" + str << "\t
    \n" str << "\t\t
    +
    + + checked<% end %>> +
    +
    checked<% end %>> -- cgit v1.2.3 From 8da336b7aa201abea73b4b7844318ad323c6cd62 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 23 Feb 2022 16:42:34 +0100 Subject: Move the "watch history" checkbox under the "user" section --- src/invidious/views/user/preferences.ecr | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index 1e16c5ec..a584cedb 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -22,11 +22,6 @@ checked<% end %>>
    -
    - - checked<% end %>> -
    -
    checked<% end %>> @@ -211,6 +206,11 @@ <% if env.get? "user" %> <%= translate(locale, "preferences_category_subscription") %> +
    + + checked<% end %>> +
    +
    checked<% end %>> -- cgit v1.2.3 From 60828870701fda890357ac6b9e91202ae24229a6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 23 Feb 2022 16:43:59 +0100 Subject: Add missing '_label' suffix to translation identifier --- locales/en-US.json | 2 +- src/invidious/views/user/preferences.ecr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 61e7840c..1335d384 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -65,7 +65,7 @@ "preferences_continue_autoplay_label": "Autoplay next video: ", "preferences_listen_label": "Listen by default: ", "preferences_local_label": "Proxy videos: ", - "preferences_watch_history": "Enable watch history: ", + "preferences_watch_history_label": "Enable watch history: ", "preferences_speed_label": "Default speed: ", "preferences_quality_label": "Preferred video quality: ", "preferences_quality_option_dash": "DASH (adaptative quality)", diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index a584cedb..dbb5e9db 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -207,7 +207,7 @@ <%= translate(locale, "preferences_category_subscription") %>
    - + checked<% end %>>
    -- cgit v1.2.3 From 919413e2b90371d63d88c86305575c17cef6445d Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Wed, 23 Feb 2022 22:39:43 -0600 Subject: Move captcha endpoint into Login route --- src/invidious.cr | 2 -- src/invidious/routes/captcha.cr | 8 -------- src/invidious/routes/login.cr | 7 +++++++ src/invidious/routing.cr | 1 + 4 files changed, 8 insertions(+), 10 deletions(-) delete mode 100644 src/invidious/routes/captcha.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index dc055e59..140a9f7b 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -391,8 +391,6 @@ end Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager - - Invidious::Routing.get "/Captcha", Invidious::Routes::Captcha, :get {% end %} Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht diff --git a/src/invidious/routes/captcha.cr b/src/invidious/routes/captcha.cr deleted file mode 100644 index a1d95a4f..00000000 --- a/src/invidious/routes/captcha.cr +++ /dev/null @@ -1,8 +0,0 @@ -module Invidious::Routes::Captcha - def self.get(env) - headers = HTTP::Headers{":authority" => "accounts.google.com"} - response = YT_POOL.client &.get(env.request.resource, headers) - env.response.headers["Content-Type"] = response.headers["Content-Type"] - response.body - end -end diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 65b337d1..99fc13a2 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -481,4 +481,11 @@ module Invidious::Routes::Login env.redirect referer end + + def self.captcha(env) + headers = HTTP::Headers{":authority" => "accounts.google.com"} + response = YT_POOL.client &.get(env.request.resource, headers) + env.response.headers["Content-Type"] = response.headers["Content-Type"] + response.body + end end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 5efe1bd8..d539d891 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -15,6 +15,7 @@ macro define_user_routes Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page Invidious::Routing.post "/login", Invidious::Routes::Login, :login Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout + Invidious::Routing.get "/Captcha", Invidious::Routes::Login, :captcha # User preferences Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show -- cgit v1.2.3 From e215a20a0ac3dd4a3141f842ec6dd90b54cb67c3 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Wed, 23 Feb 2022 22:41:34 -0600 Subject: Move live endpoints into Channels route --- src/invidious.cr | 7 +++---- src/invidious/routes/channels.cr | 33 +++++++++++++++++++++++++++++++++ src/invidious/routes/live.cr | 34 ---------------------------------- 3 files changed, 36 insertions(+), 38 deletions(-) delete mode 100644 src/invidious/routes/live.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 140a9f7b..e32c1086 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -324,6 +324,9 @@ end Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about + Invidious::Routing.get "/channel/:ucid/live", Invidious::Routes::Channels, :live + Invidious::Routing.get "/user/:user/live", Invidious::Routes::Channels, :live + Invidious::Routing.get "/c/:user/live", Invidious::Routes::Channels, :live ["", "/videos", "/playlists", "/community", "/about"].each do |path| # /c/LinusTechTips @@ -400,10 +403,6 @@ Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails -Invidious::Routing.get "/channel/:ucid/live", Invidious::Routes::Live, :check -Invidious::Routing.get "/user/:user/live", Invidious::Routes::Live, :check -Invidious::Routing.get "/c/:user/live", Invidious::Routes::Live, :check - # API routes (macro) define_v1_api_routes() Invidious::Routing.get "/api/v1/auth/notifications", Invidious::Routes::API::V1::Authenticated, :notifications_get diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 6cb1e1f7..cd2e3323 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -147,6 +147,39 @@ module Invidious::Routes::Channels end end + def self.live(env) + locale = env.get("preferences").as(Preferences).locale + + # Appears to be a bug in routing, having several routes configured + # as `/a/:a`, `/b/:a`, `/c/:a` results in 404 + value = env.request.resource.split("/")[2] + body = "" + {"channel", "user", "c"}.each do |type| + response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1") + if response.status_code == 200 + body = response.body + end + end + + video_id = body.match(/'VIDEO_ID': "(?[a-zA-Z0-9_-]{11})"/).try &.["id"]? + if video_id + params = [] of String + env.params.query.each do |k, v| + params << "#{k}=#{v}" + end + params = params.join("&") + + url = "/watch?v=#{video_id}" + if !params.empty? + url += "&#{params}" + end + + env.redirect url + else + env.redirect "/channel/#{value}" + end + end + private def self.fetch_basic_information(env) locale = env.get("preferences").as(Preferences).locale diff --git a/src/invidious/routes/live.cr b/src/invidious/routes/live.cr deleted file mode 100644 index e55111ce..00000000 --- a/src/invidious/routes/live.cr +++ /dev/null @@ -1,34 +0,0 @@ -module Invidious::Routes::Live - def self.check(env) - locale = env.get("preferences").as(Preferences).locale - - # Appears to be a bug in routing, having several routes configured - # as `/a/:a`, `/b/:a`, `/c/:a` results in 404 - value = env.request.resource.split("/")[2] - body = "" - {"channel", "user", "c"}.each do |type| - response = YT_POOL.client &.get("/#{type}/#{value}/live?disable_polymer=1") - if response.status_code == 200 - body = response.body - end - end - - video_id = body.match(/'VIDEO_ID': "(?[a-zA-Z0-9_-]{11})"/).try &.["id"]? - if video_id - params = [] of String - env.params.query.each do |k, v| - params << "#{k}=#{v}" - end - params = params.join("&") - - url = "/watch?v=#{video_id}" - if !params.empty? - url += "&#{params}" - end - - env.redirect url - else - env.redirect "/channel/#{value}" - end - end -end -- cgit v1.2.3 From d5f43bae9222cd0ba8a418615272d3edd996a4a8 Mon Sep 17 00:00:00 2001 From: matthewmcgarvey Date: Wed, 23 Feb 2022 22:45:07 -0600 Subject: Combine notifications endpoints and move them --- src/invidious.cr | 2 -- src/invidious/routes/api/v1/authenticated.cr | 14 +++----------- src/invidious/routing.cr | 3 +++ 3 files changed, 6 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index e32c1086..db3921f6 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -405,8 +405,6 @@ Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails # API routes (macro) define_v1_api_routes() -Invidious::Routing.get "/api/v1/auth/notifications", Invidious::Routes::API::V1::Authenticated, :notifications_get -Invidious::Routing.post "/api/v1/auth/notifications", Invidious::Routes::API::V1::Authenticated, :notifications_post # Video playback (macros) define_api_manifest_routes() diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 6ced4edb..b559a01a 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -398,19 +398,11 @@ module Invidious::Routes::API::V1::Authenticated env.response.status_code = 204 end - def self.notifications_get(env) + def self.notifications(env) env.response.content_type = "text/event-stream" - topics = env.params.query["topics"]?.try &.split(",").uniq.first(1000) - topics ||= [] of String - - create_notification_stream(env, topics, CONNECTION_CHANNEL) - end - - def self.notifications_post(env) - env.response.content_type = "text/event-stream" - - topics = env.params.body["topics"]?.try &.split(",").uniq.first(1000) + raw_topics = env.params.body["topics"]? || env.params.query["topics"]? + topics = raw_topics.try &.split(",").uniq.first(1000) topics ||= [] of String create_notification_stream(env, topics, CONNECTION_CHANNEL) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index d539d891..bd72c577 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -96,6 +96,9 @@ macro define_v1_api_routes Invidious::Routing.post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token Invidious::Routing.post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token + Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + # Misc Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist -- cgit v1.2.3 From 004e37105106b980736aa026c65ae86c8dc8a828 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 24 Feb 2022 22:37:54 +0100 Subject: Don't double-encode file title --- src/invidious/routes/video_playback.cr | 4 +++- src/invidious/routes/watch.cr | 7 ++----- 2 files changed, 5 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index df243945..3a92ef96 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -164,7 +164,9 @@ module Invidious::Routes::VideoPlayback if title = query_params["title"]? # https://blog.fastmail.com/2011/06/24/download-non-english-filenames/ - env.response.headers["Content-Disposition"] = "attachment; filename=\"#{URI.encode_www_form(title)}\"; filename*=UTF-8''#{URI.encode_www_form(title)}" + filename = URI.encode_www_form(title, space_to_plus: false) + header = "attachment; filename=\"#{filename}\"; filename*=UTF-8''#{filename}" + env.response.headers["Content-Disposition"] = header end if !resp.headers.includes_word?("Transfer-Encoding", "chunked") diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 7688d2a8..94148bc0 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -304,12 +304,9 @@ module Invidious::Routes::Watch end download_widget = JSON.parse(selection) - extension = download_widget["ext"].as_s - filename = URI.encode_www_form( - "#{video_id}-#{title}.#{extension}", - space_to_plus: false - ) + extension = download_widget["ext"].as_s + filename = "#{video_id}-#{title}.#{extension}" # Pass form parameters as URL parameters for the handlers of both # /latest_version and /api/v1/captions. This avoids an un-necessary -- cgit v1.2.3 From 78c447829a605cfc6b82fb9dcb4e01057a17cec5 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Fri, 25 Feb 2022 02:11:30 +0100 Subject: Increase size of links displayed in video description --- src/invidious/comments.cr | 6 +++--- src/invidious/helpers/utils.cr | 11 +++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index ab9fcc8b..e2c7b3a0 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -587,7 +587,7 @@ def content_to_comment_html(content) end end - text = %(#{text}) + text = %(#{reduce_uri(url)}) elsif watch_endpoint = run["navigationEndpoint"]["watchEndpoint"]? length_seconds = watch_endpoint["startTimeSeconds"]? video_id = watch_endpoint["videoId"].as_s @@ -595,10 +595,10 @@ def content_to_comment_html(content) if length_seconds && length_seconds.as_i > 0 text = %(#{text}) else - text = %(#{text}) + text = %(#{reduce_uri("/watch?v=#{video_id}")}) end elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s - text = %(#{text}) + text = %(#{reduce_uri(url)}) end end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index a58a21b1..f8a7873d 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -365,3 +365,14 @@ def fetch_random_instance return filtered_instance_list.sample(1)[0] end + +def reduce_uri(uri : URI | String, max_length : Int32? = 50, suffix : String? = "...") : String + str = uri.to_s.sub(/https?:\/\//, "") + if !max_length.nil? && str.size > max_length + str = str[0, max_length] + if !suffix.nil? + str = "#{str}#{suffix}" + end + end + return str +end -- cgit v1.2.3 From 0f1bb3fb3be085b3234d4baa3e512ef927aff4d9 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Fri, 25 Feb 2022 11:47:07 +0100 Subject: Update reduce_uri signature Following request_change at : - https://github.com/iv-org/invidious/pull/2936#discussion_r814436660 --- src/invidious/helpers/utils.cr | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index f8a7873d..8180ab6f 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -366,13 +366,10 @@ def fetch_random_instance return filtered_instance_list.sample(1)[0] end -def reduce_uri(uri : URI | String, max_length : Int32? = 50, suffix : String? = "...") : String +def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = "…") : String str = uri.to_s.sub(/https?:\/\//, "") - if !max_length.nil? && str.size > max_length - str = str[0, max_length] - if !suffix.nil? - str = "#{str}#{suffix}" - end + if str.size > max_length + str = "#{str[0, max_length]}#{suffix}" end return str end -- cgit v1.2.3 From 420c458b6adab8a6e35a7612c9eb6a0ba2382440 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Fri, 25 Feb 2022 21:07:12 +0100 Subject: Update links related to youtube.com Following comment at : - https://github.com/iv-org/invidious/pull/2936#discussion_r814435888 --- src/invidious/comments.cr | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index e2c7b3a0..ae8f052a 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -573,10 +573,11 @@ def content_to_comment_html(content) if run["navigationEndpoint"]? if url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s + base_url = URI.parse(url) url = URI.parse(url) if url.host == "youtu.be" - url = "/watch?v=#{url.request_target.lstrip('/')}" + url = "youtube.com/watch?v=#{url.request_target.lstrip('/')}" elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") if url.path == "/redirect" # Sometimes, links can be corrupted (why?) so make sure to fallback @@ -587,7 +588,12 @@ def content_to_comment_html(content) end end - text = %(#{reduce_uri(url)}) + if base_url.host.not_nil!.ends_with?("youtube.com") && base_url.path != "/redirect" + displayed_url = "youtube.com#{base_url.request_target}" + else + displayed_url = url + end + text = %(#{reduce_uri(displayed_url)}) elsif watch_endpoint = run["navigationEndpoint"]["watchEndpoint"]? length_seconds = watch_endpoint["startTimeSeconds"]? video_id = watch_endpoint["videoId"].as_s @@ -595,7 +601,7 @@ def content_to_comment_html(content) if length_seconds && length_seconds.as_i > 0 text = %(#{text}) else - text = %(#{reduce_uri("/watch?v=#{video_id}")}) + text = %(#{reduce_uri("youtube.com/watch?v=#{video_id}")}) end elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s text = %(#{reduce_uri(url)}) -- cgit v1.2.3 From 19805b91d90cc6e8648a213d0b46636392484f38 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Sat, 26 Feb 2022 17:53:39 +0100 Subject: Patch links related to youtube.com Related to followings comments : - https://github.com/iv-org/invidious/pull/2936#discussion_r815253405 --- src/invidious/comments.cr | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index ae8f052a..1fd3dcfd 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -573,26 +573,24 @@ def content_to_comment_html(content) if run["navigationEndpoint"]? if url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s - base_url = URI.parse(url) url = URI.parse(url) + displayed_url = url if url.host == "youtu.be" - url = "youtube.com/watch?v=#{url.request_target.lstrip('/')}" + url = "/watch?v=#{url.request_target.lstrip('/')}" + displayed_url = "youtube.com#{url}" elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") if url.path == "/redirect" # Sometimes, links can be corrupted (why?) so make sure to fallback # nicely. See https://github.com/iv-org/invidious/issues/2682 url = HTTP::Params.parse(url.query.not_nil!)["q"]? || "" + displayed_url = url else url = url.request_target + displayed_url = "youtube.com#{url}" end end - if base_url.host.not_nil!.ends_with?("youtube.com") && base_url.path != "/redirect" - displayed_url = "youtube.com#{base_url.request_target}" - else - displayed_url = url - end text = %(#{reduce_uri(displayed_url)}) elsif watch_endpoint = run["navigationEndpoint"]["watchEndpoint"]? length_seconds = watch_endpoint["startTimeSeconds"]? -- cgit v1.2.3 From f7b557eed1c8e55002608ee9117cf9523b24bafd Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 6 Mar 2022 01:12:57 +0100 Subject: API: fix suggestions not workin Closes #2914 Thanks to @TiA4f8R for the help --- src/invidious/routes/api/v1/search.cr | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index 0b0853b1..5666460d 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -43,20 +43,20 @@ module Invidious::Routes::API::V1::Search end def self.search_suggestions(env) - locale = env.get("preferences").as(Preferences).locale - region = env.params.query["region"]? + preferences = env.get("preferences").as(Preferences) + region = env.params.query["region"]? || preferences.region env.response.content_type = "application/json" - query = env.params.query["q"]? - query ||= "" + query = env.params.query["q"]? || "" begin - headers = HTTP::Headers{":authority" => "suggestqueries.google.com"} - response = YT_POOL.client &.get("/complete/search?hl=en&gl=#{region}&client=youtube&ds=yt&q=#{URI.encode_www_form(query)}&callback=suggestCallback", headers).body + client = HTTP::Client.new("suggestqueries-clients6.youtube.com") + url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&xssi=t&gs_ri=youtube&ds=yt" + + response = client.get(url).body - body = response[35..-2] - body = JSON.parse(body).as_a + body = JSON.parse(response[5..-1]).as_a suggestions = body[1].as_a[0..-2] JSON.build do |json| -- cgit v1.2.3 From 6d3b907307346f8774d012b9ee24f203d5d6d48d Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 11 Mar 2022 20:51:12 +0100 Subject: Update --help to mention that --migrate is still in beta --- src/invidious.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index abc459b7..a470c6b6 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -103,7 +103,7 @@ Kemal.config.extra_options do |parser| puts SOFTWARE.to_pretty_json exit end - parser.on("--migrate", "Run any migrations") do + parser.on("--migrate", "Run any migrations (beta, use at your own risk!!") do Invidious::Database::Migrator.new(PG_DB).migrate exit end -- cgit v1.2.3 From 357ba2f4f637a9407c246e6eda14d01a6360612e Mon Sep 17 00:00:00 2001 From: AHOHNMYC <24810600+AHOHNMYC@users.noreply.github.com> Date: Sun, 13 Mar 2022 08:53:27 +0300 Subject: Uppercase some first letters --- src/invidious/helpers/i18n.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 39e183f2..982b97d8 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -29,10 +29,10 @@ LOCALES_LIST = { "pt-BR" => "Português Brasileiro", # Portuguese (Brazil) "pt-PT" => "Português de Portugal", # Portuguese (Portugal) "ro" => "Română", # Romanian - "ru" => "русский", # Russian + "ru" => "Русский", # Russian "sq" => "Shqip", # Albanian - "sr" => "srpski (latinica)", # Serbian (Latin) - "sr_Cyrl" => "српски (ћирилица)", # Serbian (Cyrillic) + "sr" => "Srpski (latinica)", # Serbian (Latin) + "sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic) "sv-SE" => "Svenska", # Swedish "tr" => "Türkçe", # Turkish "uk" => "Українська", # Ukrainian -- cgit v1.2.3 From aa09bbe23dcb1cf0ec04d330722905c6bb5caf86 Mon Sep 17 00:00:00 2001 From: Jonas Wunderlich Date: Sun, 13 Mar 2022 20:16:30 +0100 Subject: Done some refactoring --- src/invidious/channels/about.cr | 5 +++-- src/invidious/comments.cr | 5 +++-- src/invidious/helpers/serialized_yt_data.cr | 2 ++ src/invidious/videos.cr | 8 +++++--- src/invidious/views/channel.ecr | 2 +- src/invidious/views/community.ecr | 2 +- src/invidious/views/components/item.ecr | 8 ++++---- src/invidious/views/playlists.ecr | 2 +- src/invidious/views/watch.ecr | 6 +++--- 9 files changed, 23 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 33613260..d48fd1fb 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -71,9 +71,10 @@ def get_about_info(ucid, locale) : AboutChannel # if banner.includes? "channels/c4/default_banner" # banner = nil # end - author_verified_badges = initdata["header"]?.try &.["c4TabbedHeaderRenderer"]?.try &.["badges"]? + # author_verified_badges = initdata["header"]?.try &.["c4TabbedHeaderRenderer"]?.try &.["badges"]? + author_verified_badge = initdata["header"].dig?("c4TabbedHeaderRenderer", "badges", 0, "metadataBadgeRenderer", "tooltip") + author_verified = (author_verified_badge && author_verified_badge == "Verified") - author_verified = (author_verified_badges && author_verified_badges.size > 0) description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" description_html = HTML.escape(description) diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 5215122e..d94f213f 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -145,8 +145,9 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b content_html = node_comment["contentText"]?.try { |t| parse_content(t) } || "" author = node_comment["authorText"]?.try &.["simpleText"]? || "" - verified = (node_comment["authorCommentBadge"]? != nil) - json.field "verified", (verified || false) + + json.field "verified", (node_comment["authorCommentBadge"]? != nil) + json.field "author", author json.field "authorThumbnails" do json.array do diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 186bca25..3918bd13 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -142,7 +142,9 @@ struct SearchPlaylist json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" + json.field "authorVerified", self.author_verified + json.field "videoCount", self.video_count json.field "videos" do json.array do diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index bbf3afa2..66952c93 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -594,7 +594,7 @@ struct Video end def author_verified : Bool - info["authorVerified"].as_bool + info["authorVerified"].try &.as_bool || false end def sub_count_text : String @@ -854,6 +854,7 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? end author_verified = (author_verified_badge && author_verified_badge.size > 0).to_s + ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } # "4,088,033 views", only available on compact renderer @@ -1071,9 +1072,10 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url") - author_verified_badge = author_info.try &.["badges"]? - params["authorVerified"] = JSON::Any.new((author_verified_badge && author_verified_badge.size > 0) || false) + author_verified_badge = author_info.try &.dig?("badges", 0, "metadataBadgeRenderer", "tooltip") + params["authorVerified"] = JSON::Any.new((author_verified_badge && author_verified_badge == "Verified")) + params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 197c636b..92f81ee4 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -20,7 +20,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 10ac5f04..3bc29e55 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -19,7 +19,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 05478eeb..cc4ded74 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -8,7 +8,7 @@ "/> <% end %> -

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -30,7 +30,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

    <% when MixVideo %> @@ -45,7 +45,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %><% if !item.is_a?(MixVideo) && !item.author_verified.nil? && item.author_verified %> <% end %>

    +

    <%= HTML.escape(item.author) %>

    <% when PlaylistVideo %> @@ -142,7 +142,7 @@
    <% endpoint_params = "?v=#{item.id}" %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 94d7a753..c8718e7b 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -19,7 +19,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index bfd7821a..74a5e69f 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -206,7 +206,7 @@ we're going to need to do it here in order to allow for translations. <% if !video.author_thumbnail.empty? %> <% end %> - <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %> + <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %>
    @@ -280,9 +280,9 @@ we're going to need to do it here in order to allow for translations.
    <% if rv["ucid"]? %> - "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> + "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> <% else %> - <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> + <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> <% end %>
    -- cgit v1.2.3 From ed265cfdcd131b9df5398d899cc5d7036a5b7846 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 16 Mar 2022 09:07:30 +0100 Subject: Request minified JSON from innertube (#2974) --- src/invidious/yt_backend/youtube_api.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 5bbd9213..d1b52a5a 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -401,7 +401,7 @@ module YoutubeAPI client_config ||= DEFAULT_CLIENT_CONFIG # Query parameters - url = "#{endpoint}?key=#{client_config.api_key}" + url = "#{endpoint}?key=#{client_config.api_key}&prettyPrint=false" headers = HTTP::Headers{ "Content-Type" => "application/json; charset=UTF-8", -- cgit v1.2.3 From 611e7e9dd85b9c515ab990bd6a08a98757fd8319 Mon Sep 17 00:00:00 2001 From: Jonas Wunderlich Date: Sat, 26 Mar 2022 20:13:33 +0100 Subject: Changed icon to checkmark and for verified author to checkmark-circle --- src/invidious/comments.cr | 6 ++++-- src/invidious/views/channel.ecr | 2 +- src/invidious/views/community.ecr | 2 +- src/invidious/views/components/item.ecr | 6 +++--- src/invidious/views/playlists.ecr | 2 +- src/invidious/views/watch.ecr | 6 +++--- 6 files changed, 13 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index d94f213f..54cede37 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -331,8 +331,10 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end author_name = HTML.escape(child["author"].as_s) - if child["verified"]?.try &.as_bool - author_name += " " + if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool + author_name += " " + elsif child["verified"]?.try &.as_bool + author_name += " " end html << <<-END_HTML
    diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 92f81ee4..d6e653b4 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -20,7 +20,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 3bc29e55..6b8ccd92 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -19,7 +19,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index cc4ded74..86038f28 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -8,7 +8,7 @@ "/> <% end %> -

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -30,7 +30,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

    <% when MixVideo %> @@ -142,7 +142,7 @@
    <% endpoint_params = "?v=#{item.id}" %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index c8718e7b..87e0c75d 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -19,7 +19,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 74a5e69f..d79c6dc8 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -206,7 +206,7 @@ we're going to need to do it here in order to allow for translations. <% if !video.author_thumbnail.empty? %> <% end %> - <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %> + <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %>
    @@ -280,9 +280,9 @@ we're going to need to do it here in order to allow for translations.
    <% if rv["ucid"]? %> - "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> + "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> <% else %> - <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> + <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> <% end %>
    -- cgit v1.2.3 From ec3e67e0d222d1f4c4bb278d7ce8ec804bebc137 Mon Sep 17 00:00:00 2001 From: Jonas Wunderlich Date: Sat, 26 Mar 2022 20:18:24 +0100 Subject: Wait that was too much replacing --- src/invidious/views/channel.ecr | 2 +- src/invidious/views/community.ecr | 2 +- src/invidious/views/components/item.ecr | 6 +++--- src/invidious/views/playlists.ecr | 2 +- src/invidious/views/watch.ecr | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index d6e653b4..92f81ee4 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -20,7 +20,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 6b8ccd92..3bc29e55 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -19,7 +19,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 86038f28..cc4ded74 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -8,7 +8,7 @@ "/> <% end %> -

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -30,7 +30,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

    +

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

    <% when MixVideo %> @@ -142,7 +142,7 @@
    <% endpoint_params = "?v=#{item.id}" %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 87e0c75d..c8718e7b 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -19,7 +19,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index d79c6dc8..74a5e69f 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -206,7 +206,7 @@ we're going to need to do it here in order to allow for translations. <% if !video.author_thumbnail.empty? %> <% end %> - <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %> + <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %>
    @@ -280,9 +280,9 @@ we're going to need to do it here in order to allow for translations.
    <% if rv["ucid"]? %> - "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> + "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> <% else %> - <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> + <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> <% end %>
    -- cgit v1.2.3 From f9b8bc006f0375d4f7d24f2e671d0f8ab38059dd Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 7 Mar 2022 00:52:54 +0100 Subject: Create a search processors module --- src/invidious.cr | 1 + src/invidious/routes/api/v1/channels.cr | 2 +- src/invidious/search.cr | 47 +++------------------------- src/invidious/search/processors.cr | 54 +++++++++++++++++++++++++++++++++ 4 files changed, 60 insertions(+), 44 deletions(-) create mode 100644 src/invidious/search/processors.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index a470c6b6..9f3d5d10 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -35,6 +35,7 @@ require "./invidious/frontend/*" require "./invidious/*" require "./invidious/channels/*" require "./invidious/user/*" +require "./invidious/search/*" require "./invidious/routes/**" require "./invidious/jobs/**" diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index c4d6643a..c4395353 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -262,7 +262,7 @@ module Invidious::Routes::API::V1::Channels page = env.params.query["page"]?.try &.to_i? page ||= 1 - search_results = channel_search(query, page, ucid) + search_results = Invidious::Search::Processors.channel(query, page, ucid) JSON.build do |json| json.array do search_results.each do |item| diff --git a/src/invidious/search.cr b/src/invidious/search.cr index ae106bf6..af854653 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -5,35 +5,6 @@ class ChannelSearchException < InfoException end end -def channel_search(query, page, channel) : Array(SearchItem) - response = YT_POOL.client &.get("/channel/#{channel}") - - if response.status_code == 404 - response = YT_POOL.client &.get("/user/#{channel}") - response = YT_POOL.client &.get("/c/#{channel}") if response.status_code == 404 - initial_data = extract_initial_data(response.body) - ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?) - raise ChannelSearchException.new(channel) if !ucid - else - ucid = channel - end - - continuation = produce_channel_search_continuation(ucid, query, page) - response_json = YoutubeAPI.browse(continuation) - - continuation_items = response_json["onResponseReceivedActions"]? - .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - - return [] of SearchItem if !continuation_items - - items = [] of SearchItem - continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each do |item| - extract_item(item["itemSectionRenderer"]["contents"].as_a[0]).try { |t| items << t } - end - - return items -end - def search(query, search_params = produce_search_params(content_type: "all"), region = nil) : Array(SearchItem) return [] of SearchItem if query.empty? @@ -175,11 +146,6 @@ def produce_channel_search_continuation(ucid, query, page) end def process_search_query(query, page, user, region) - if user - user = user.as(Invidious::User) - view_name = "subscriptions_#{sha256(user.email)}" - end - channel = nil content_type = "all" date = "" @@ -215,16 +181,11 @@ def process_search_query(query, page, user, region) search_query = (query.split(" ") - operators).join(" ") if channel - items = channel_search(search_query, page, channel) + items = Invidious::Search::Processors.channel(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) + if user + user = user.as(Invidious::User) + items = Invidious::Search::Processors.subscriptions(query, page, user) else items = [] of ChannelVideo end diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr new file mode 100644 index 00000000..c5327f34 --- /dev/null +++ b/src/invidious/search/processors.cr @@ -0,0 +1,54 @@ +module Invidious::Search + module Processors + extend self + + # Search a youtube channel + # TODO: clean code, and rely more on YoutubeAPI + def channel(query, page, channel) : Array(SearchItem) + response = YT_POOL.client &.get("/channel/#{channel}") + + if response.status_code == 404 + response = YT_POOL.client &.get("/user/#{channel}") + response = YT_POOL.client &.get("/c/#{channel}") if response.status_code == 404 + initial_data = extract_initial_data(response.body) + ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?) + raise ChannelSearchException.new(channel) if !ucid + else + ucid = channel + end + + continuation = produce_channel_search_continuation(ucid, query, page) + response_json = YoutubeAPI.browse(continuation) + + continuation_items = response_json["onResponseReceivedActions"]? + .try &.[0]["appendContinuationItemsAction"]["continuationItems"] + + return [] of SearchItem if !continuation_items + + items = [] of SearchItem + continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each do |item| + extract_item(item["itemSectionRenderer"]["contents"].as_a[0]).try { |t| items << t } + end + + return items + end + + # Search inside of user subscriptions + def subscriptions(query, page, user : Invidious::User) : Array(ChannelVideo) + view_name = "subscriptions_#{sha256(user.email)}" + + return 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;", + query, (page - 1) * 20, + as: ChannelVideo + ) + end + end +end -- cgit v1.2.3 From 80417281c437f10dc6653648d6def00c8ba167d6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Mar 2022 22:32:34 +0100 Subject: Add a struct for search filters --- src/invidious/search/filters.cr | 79 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/invidious/search/filters.cr (limited to 'src') diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr new file mode 100644 index 00000000..75ac287a --- /dev/null +++ b/src/invidious/search/filters.cr @@ -0,0 +1,79 @@ +module Invidious::Search + struct Filters + # Values correspond to { "2:embedded": { "1:varint": }} + # except for "None" which is only used by us (= nothing selected) + enum Date + None = 0 + Hour = 1 + Today = 2 + Week = 3 + Month = 4 + Year = 5 + end + + # Values correspond to { "2:embedded": { "2:varint": }} + # except for "All" which is only used by us (= nothing selected) + enum Type + All = 0 + Video = 1 + Channel = 2 + Playlist = 3 + Movie = 4 + + # Has it been removed? + # (Not available on youtube's UI) + Show = 5 + end + + # Values correspond to { "2:embedded": { "3:varint": }} + # except for "None" which is only used by us (= nothing selected) + enum Duration + None = 0 + Short = 1 # "Under 4 minutes" + Long = 2 # "Over 20 minutes" + Medium = 3 # "4 - 20 minutes" + end + + # Note: flag enums automatically generate + # "none" and "all" members + @[Flags] + enum Features + Live + FourK # "4K" + HD + Subtitles # "Subtitles/CC" + CCommons # "Creative Commons" + ThreeSixty # "360°" + VR180 + ThreeD # "3D" + HDR + Location + Purchased + end + + # Values correspond to { "1:varint": } + enum Sort + Relevance = 0 + Rating = 1 + Date = 2 + Views = 3 + end + + # Parameters are sorted as on Youtube + property date : Date + property type : Type + property duration : Duration + property features : Features + property sort : Sort + + def initialize( + *, # All parameters must be named + @date : Date = Date::None, + @type : Type = Type::All, + @duration : Duration = Duration::None, + @features : Features = Features::None, + @sort : Sort = Sort::Relevance + ) + end + end +end -- cgit v1.2.3 From c01a29fe76c78d403e80d1e9000046c45ac97a72 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Mar 2022 22:37:02 +0100 Subject: Add a function to build youtube search filters (it aims at replacing produce_search_params) --- spec/invidious/helpers_spec.cr | 14 ----- spec/invidious/search/yt_filters_spec.cr | 92 ++++++++++++++++++++++++++++++++ src/invidious/search/filters.cr | 60 +++++++++++++++++++++ 3 files changed, 152 insertions(+), 14 deletions(-) create mode 100644 spec/invidious/search/yt_filters_spec.cr (limited to 'src') diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr index b2436989..5ecebef3 100644 --- a/spec/invidious/helpers_spec.cr +++ b/spec/invidious/helpers_spec.cr @@ -29,20 +29,6 @@ Spectator.describe "Helper" do end end - describe "#produce_search_params" do - it "correctly produces token for searching with specified filters" do - expect(produce_search_params).to eq("CAASAhABSAA%3D") - - expect(produce_search_params(sort: "upload_date", content_type: "video")).to eq("CAISAhABSAA%3D") - - expect(produce_search_params(content_type: "playlist")).to eq("CAASAhADSAA%3D") - - expect(produce_search_params(sort: "date", content_type: "video", features: ["hd", "cc", "purchased", "hdr"])).to eq("CAISCxABIAEwAUgByAEBSAA%3D") - - expect(produce_search_params(content_type: "channel")).to eq("CAASAhACSAA%3D") - end - end - describe "#produce_comment_continuation" do it "correctly produces a continuation token for comments" do expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") diff --git a/spec/invidious/search/yt_filters_spec.cr b/spec/invidious/search/yt_filters_spec.cr new file mode 100644 index 00000000..27357058 --- /dev/null +++ b/spec/invidious/search/yt_filters_spec.cr @@ -0,0 +1,92 @@ +require "../../../src/invidious/search/filters" + +require "http/params" +require "spectator" + +Spectator.configure do |config| + config.fail_blank + config.randomize +end + +# Encoded filter values are extracted from the search +# page of Youtube with any browser devtools HTML inspector. + +DATE_FILTERS = { + Invidious::Search::Filters::Date::Hour => "EgIIAQ%3D%3D", + Invidious::Search::Filters::Date::Today => "EgIIAg%3D%3D", + Invidious::Search::Filters::Date::Week => "EgIIAw%3D%3D", + Invidious::Search::Filters::Date::Month => "EgIIBA%3D%3D", + Invidious::Search::Filters::Date::Year => "EgIIBQ%3D%3D", +} + +TYPE_FILTERS = { + Invidious::Search::Filters::Type::Video => "EgIQAQ%3D%3D", + Invidious::Search::Filters::Type::Channel => "EgIQAg%3D%3D", + Invidious::Search::Filters::Type::Playlist => "EgIQAw%3D%3D", + Invidious::Search::Filters::Type::Movie => "EgIQBA%3D%3D", +} + +DURATION_FILTERS = { + Invidious::Search::Filters::Duration::Short => "EgIYAQ%3D%3D", + Invidious::Search::Filters::Duration::Medium => "EgIYAw%3D%3D", + Invidious::Search::Filters::Duration::Long => "EgIYAg%3D%3D", +} + +FEATURE_FILTERS = { + Invidious::Search::Filters::Features::Live => "EgJAAQ%3D%3D", + Invidious::Search::Filters::Features::FourK => "EgJwAQ%3D%3D", + Invidious::Search::Filters::Features::HD => "EgIgAQ%3D%3D", + Invidious::Search::Filters::Features::Subtitles => "EgIoAQ%3D%3D", + Invidious::Search::Filters::Features::CCommons => "EgIwAQ%3D%3D", + Invidious::Search::Filters::Features::ThreeSixty => "EgJ4AQ%3D%3D", + Invidious::Search::Filters::Features::VR180 => "EgPQAQE%3D", + Invidious::Search::Filters::Features::ThreeD => "EgI4AQ%3D%3D", + Invidious::Search::Filters::Features::HDR => "EgPIAQE%3D", + Invidious::Search::Filters::Features::Location => "EgO4AQE%3D", + Invidious::Search::Filters::Features::Purchased => "EgJIAQ%3D%3D", +} + +SORT_FILTERS = { + Invidious::Search::Filters::Sort::Relevance => "", + Invidious::Search::Filters::Sort::Date => "CAI%3D", + Invidious::Search::Filters::Sort::Views => "CAM%3D", + Invidious::Search::Filters::Sort::Rating => "CAE%3D", +} + +Spectator.describe Invidious::Search::Filters do + # ------------------- + # Encode YT params + # ------------------- + + describe "#to_yt_params" do + sample DATE_FILTERS do |value, result| + it "Encodes upload date filter '#{value}'" do + expect(described_class.new(date: value).to_yt_params).to eq(result) + end + end + + sample TYPE_FILTERS do |value, result| + it "Encodes content type filter '#{value}'" do + expect(described_class.new(type: value).to_yt_params).to eq(result) + end + end + + sample DURATION_FILTERS do |value, result| + it "Encodes duration filter '#{value}'" do + expect(described_class.new(duration: value).to_yt_params).to eq(result) + end + end + + sample FEATURE_FILTERS do |value, result| + it "Encodes feature filter '#{value}'" do + expect(described_class.new(features: value).to_yt_params).to eq(result) + end + end + + sample SORT_FILTERS do |value, result| + it "Encodes sort filter '#{value}'" do + expect(described_class.new(sort: value).to_yt_params).to eq(result) + end + end + end +end diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr index 75ac287a..f1fd2695 100644 --- a/src/invidious/search/filters.cr +++ b/src/invidious/search/filters.cr @@ -1,3 +1,6 @@ +require "protodec/utils" +require "http/params" + module Invidious::Search struct Filters # Values correspond to { "2:embedded": { "1:varint": }} @@ -74,6 +77,63 @@ module Invidious::Search @features : Features = Features::None, @sort : Sort = Sort::Relevance ) + # ------------------- + # Youtube params + # ------------------- + + # Produce the youtube search parameters for the + # innertube API (base64-encoded protobuf object). + def to_yt_params(page : Int = 1) : String + # Initialize the embedded protobuf object + embedded = {} of String => Int64 + + # Add these field only if associated parameter is selected + embedded["1:varint"] = @date.to_i64 if !@date.none? + embedded["2:varint"] = @type.to_i64 if !@type.all? + embedded["3:varint"] = @duration.to_i64 if !@duration.none? + + if !@features.none? + # All features have a value of "1" when enabled, and + # the field is omitted when the feature is no selected. + embedded["4:varint"] = 1_i64 if @features.includes?(Features::HD) + embedded["5:varint"] = 1_i64 if @features.includes?(Features::Subtitles) + embedded["6:varint"] = 1_i64 if @features.includes?(Features::CCommons) + embedded["7:varint"] = 1_i64 if @features.includes?(Features::ThreeD) + embedded["8:varint"] = 1_i64 if @features.includes?(Features::Live) + embedded["9:varint"] = 1_i64 if @features.includes?(Features::Purchased) + embedded["14:varint"] = 1_i64 if @features.includes?(Features::FourK) + embedded["15:varint"] = 1_i64 if @features.includes?(Features::ThreeSixty) + embedded["23:varint"] = 1_i64 if @features.includes?(Features::Location) + embedded["25:varint"] = 1_i64 if @features.includes?(Features::HDR) + embedded["26:varint"] = 1_i64 if @features.includes?(Features::VR180) + end + + # Initialize an empty protobuf object + object = {} of String => (Int64 | String | Hash(String, Int64)) + + # As usual, everything can be omitted if it has no value + object["2:embedded"] = embedded if !embedded.empty? + + # Default sort is "relevance", so when this option is selected, + # the associated field can be omitted. + if !@sort.relevance? + object["1:varint"] = @sort.to_i64 + end + + # Add page number (if provided) + if page > 1 + object["9:varint"] = ((page - 1) * 20).to_i64 + end + + # If the object is empty, return an empty string, + # otherwise encode to protobuf then to base64 + return "" if object.empty? + + return object + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } end end end -- cgit v1.2.3 From 75c9dbaf6bec907f73c606be2a1710c9d3a68fc3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 3 Mar 2022 23:29:13 +0100 Subject: Add a function to parse youtube search parameters --- spec/invidious/search/yt_filters_spec.cr | 51 ++++++++++++++++++++++++++ src/invidious/search/filters.cr | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) (limited to 'src') diff --git a/spec/invidious/search/yt_filters_spec.cr b/spec/invidious/search/yt_filters_spec.cr index 27357058..bf7f21e7 100644 --- a/spec/invidious/search/yt_filters_spec.cr +++ b/spec/invidious/search/yt_filters_spec.cr @@ -89,4 +89,55 @@ Spectator.describe Invidious::Search::Filters do end end end + + # ------------------- + # Decode YT params + # ------------------- + + describe "#from_yt_params" do + sample DATE_FILTERS do |value, encoded| + it "Decodes upload date filter '#{value}'" do + params = HTTP::Params.parse("sp=#{encoded}") + + expect(described_class.from_yt_params(params)) + .to eq(described_class.new(date: value)) + end + end + + sample TYPE_FILTERS do |value, encoded| + it "Decodes content type filter '#{value}'" do + params = HTTP::Params.parse("sp=#{encoded}") + + expect(described_class.from_yt_params(params)) + .to eq(described_class.new(type: value)) + end + end + + sample DURATION_FILTERS do |value, encoded| + it "Decodes duration filter '#{value}'" do + params = HTTP::Params.parse("sp=#{encoded}") + + expect(described_class.from_yt_params(params)) + .to eq(described_class.new(duration: value)) + end + end + + sample FEATURE_FILTERS do |value, encoded| + it "Decodes feature filter '#{value}'" do + params = HTTP::Params.parse("sp=#{encoded}") + + expect(described_class.from_yt_params(params)) + .to eq(described_class.new(features: value)) + end + end + + sample SORT_FILTERS do |value, encoded| + it "Decodes sort filter '#{value}'" do + params = HTTP::Params.parse("sp=#{encoded}") + + expect(described_class.from_yt_params(params)) + .to eq(described_class.new(sort: value)) + end + end + end end diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr index f1fd2695..5c478257 100644 --- a/src/invidious/search/filters.cr +++ b/src/invidious/search/filters.cr @@ -135,5 +135,67 @@ module Invidious::Search .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } end + + # Function to parse the `sp` URL parameter from Youtube + # search page. It's a base64-encoded protobuf object. + def self.from_yt_params(params : HTTP::Params) : Filters + # Initialize output variable + filters = Filters.new + + # Get parameter, and check emptyness + search_params = params["sp"]? + + if search_params.nil? || search_params.empty? + return filters + end + + # Decode protobuf object + object = search_params + .try { |i| URI.decode_www_form(i) } + .try { |i| Base64.decode(i) } + .try { |i| IO::Memory.new(i) } + .try { |i| Protodec::Any.parse(i) } + + # Parse items from embedded object + if embedded = object["2:0:embedded"]? + # All the following fields (date, type, duration) are optional. + if date = embedded["1:0:varint"]? + filters.date = Date.from_value?(date.as_i) || Date::None + end + + if type = embedded["2:0:varint"]? + filters.type = Type.from_value?(type.as_i) || Type::All + end + + if duration = embedded["3:0:varint"]? + filters.duration = Duration.from_value?(duration.as_i) || Duration::None + end + + # All features should have a value of "1" when enabled, and + # the field should be omitted when the feature is no selected. + features = 0 + features += (embedded["4:0:varint"]?.try &.as_i == 1_i64) ? Features::HD.value : 0 + features += (embedded["5:0:varint"]?.try &.as_i == 1_i64) ? Features::Subtitles.value : 0 + features += (embedded["6:0:varint"]?.try &.as_i == 1_i64) ? Features::CCommons.value : 0 + features += (embedded["7:0:varint"]?.try &.as_i == 1_i64) ? Features::ThreeD.value : 0 + features += (embedded["8:0:varint"]?.try &.as_i == 1_i64) ? Features::Live.value : 0 + features += (embedded["9:0:varint"]?.try &.as_i == 1_i64) ? Features::Purchased.value : 0 + features += (embedded["14:0:varint"]?.try &.as_i == 1_i64) ? Features::FourK.value : 0 + features += (embedded["15:0:varint"]?.try &.as_i == 1_i64) ? Features::ThreeSixty.value : 0 + features += (embedded["23:0:varint"]?.try &.as_i == 1_i64) ? Features::Location.value : 0 + features += (embedded["25:0:varint"]?.try &.as_i == 1_i64) ? Features::HDR.value : 0 + features += (embedded["26:0:varint"]?.try &.as_i == 1_i64) ? Features::VR180.value : 0 + + filters.features = Features.from_value?(features) || Features::None + end + + if sort = object["1:0:varint"]? + filters.sort = Sort.from_value?(sort.as_i) || Sort::Relevance + end + + # Remove URL parameter and return result + params.delete("sp") + return filters + end end end -- cgit v1.2.3 From c888524523195382a5da171545441eaf0662ab01 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 4 Mar 2022 00:56:33 +0100 Subject: Add a function to parse invidious legacy search filters --- spec/invidious/search/iv_filters_spec.cr | 178 +++++++++++++++++++++++++++++++ src/invidious/search/filters.cr | 115 ++++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 spec/invidious/search/iv_filters_spec.cr (limited to 'src') diff --git a/spec/invidious/search/iv_filters_spec.cr b/spec/invidious/search/iv_filters_spec.cr new file mode 100644 index 00000000..6e8e6f3d --- /dev/null +++ b/spec/invidious/search/iv_filters_spec.cr @@ -0,0 +1,178 @@ +require "../../../src/invidious/search/filters" + +require "http/params" +require "spectator" + +Spectator.configure do |config| + config.fail_blank + config.randomize +end + +FEATURES_TEXT = { + Invidious::Search::Filters::Features::Live => "live", + Invidious::Search::Filters::Features::FourK => "4k", + Invidious::Search::Filters::Features::HD => "hd", + Invidious::Search::Filters::Features::Subtitles => "subtitles", + Invidious::Search::Filters::Features::CCommons => "commons", + Invidious::Search::Filters::Features::ThreeSixty => "360", + Invidious::Search::Filters::Features::VR180 => "vr180", + Invidious::Search::Filters::Features::ThreeD => "3d", + Invidious::Search::Filters::Features::HDR => "hdr", + Invidious::Search::Filters::Features::Location => "location", + Invidious::Search::Filters::Features::Purchased => "purchased", +} + +Spectator.describe Invidious::Search::Filters do + # ------------------- + # Decode (legacy) + # ------------------- + + describe "#from_legacy_filters" do + it "Decodes channel: filter" do + query = "test channel:UC123456 request" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new) + expect(chan).to eq("UC123456") + expect(qury).to eq("test request") + expect(subs).to be_false + end + + it "Decodes user: filter" do + query = "user:LinusTechTips broke something (again)" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new) + expect(chan).to eq("LinusTechTips") + expect(qury).to eq("broke something (again)") + expect(subs).to be_false + end + + it "Decodes type: filter" do + Invidious::Search::Filters::Type.each do |value| + query = "Eiffel 65 - Blue [1 Hour] type:#{value}" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(type: value)) + expect(chan).to eq("") + expect(qury).to eq("Eiffel 65 - Blue [1 Hour]") + expect(subs).to be_false + end + end + + it "Decodes content_type: filter" do + Invidious::Search::Filters::Type.each do |value| + query = "I like to watch content_type:#{value}" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(type: value)) + expect(chan).to eq("") + expect(qury).to eq("I like to watch") + expect(subs).to be_false + end + end + + it "Decodes date: filter" do + Invidious::Search::Filters::Date.each do |value| + query = "This date:#{value} is old!" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(date: value)) + expect(chan).to eq("") + expect(qury).to eq("This is old!") + expect(subs).to be_false + end + end + + it "Decodes duration: filter" do + Invidious::Search::Filters::Duration.each do |value| + query = "This duration:#{value} is old!" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(duration: value)) + expect(chan).to eq("") + expect(qury).to eq("This is old!") + expect(subs).to be_false + end + end + + it "Decodes feature: filter" do + Invidious::Search::Filters::Features.each do |value| + string = FEATURES_TEXT[value] + query = "I like my precious feature:#{string} ^^" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(features: value)) + expect(chan).to eq("") + expect(qury).to eq("I like my precious ^^") + expect(subs).to be_false + end + end + + it "Decodes features: filter" do + query = "This search has many features:vr180,cc,hdr :o" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + features = Invidious::Search::Filters::Features.flags(HDR, VR180, CCommons) + + expect(fltr).to eq(described_class.new(features: features)) + expect(chan).to eq("") + expect(qury).to eq("This search has many :o") + expect(subs).to be_false + end + + it "Decodes sort: filter" do + Invidious::Search::Filters::Sort.each do |value| + query = "Computer? sort:#{value} my files!" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new(sort: value)) + expect(chan).to eq("") + expect(qury).to eq("Computer? my files!") + expect(subs).to be_false + end + end + + it "Decodes subscriptions: filter" do + query = "enable subscriptions:true" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new) + expect(chan).to eq("") + expect(qury).to eq("enable") + expect(subs).to be_true + end + + it "Ignores junk data" do + query = "duration:I sort:like type:cleaning features:stuff date:up!" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new) + expect(chan).to eq("") + expect(qury).to eq("") + expect(subs).to be_false + end + + it "Keeps unknown keys" do + query = "to:be or:not to:be" + + fltr, chan, qury, subs = described_class.from_legacy_filters(query) + + expect(fltr).to eq(described_class.new) + expect(chan).to eq("") + expect(qury).to eq("to:be or:not to:be") + expect(subs).to be_false + end + end +end diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr index 5c478257..c5e91aae 100644 --- a/src/invidious/search/filters.cr +++ b/src/invidious/search/filters.cr @@ -77,6 +77,121 @@ module Invidious::Search @features : Features = Features::None, @sort : Sort = Sort::Relevance ) + end + + # ------------------- + # Invidious params + # ------------------- + + def self.parse_features(raw : Array(String)) : Features + # Initialize return variable + features = Features.new(0) + + raw.each do |ft| + case ft.downcase + when "live", "livestream" + features = features | Features::Live + when "4k" then features = features | Features::FourK + when "hd" then features = features | Features::HD + when "subtitles" then features = features | Features::Subtitles + when "creative_commons", "commons", "cc" + features = features | Features::CCommons + when "360" then features = features | Features::ThreeSixty + when "vr180" then features = features | Features::VR180 + when "3d" then features = features | Features::ThreeD + when "hdr" then features = features | Features::HDR + when "location" then features = features | Features::Location + when "purchased" then features = features | Features::Purchased + end + end + + return features + end + + def self.format_features(features : Features) : String + # Directly return an empty string if there are no features + return "" if features.none? + + # Initialize return variable + str = [] of String + + str << "live" if features.live? + str << "4k" if features.four_k? + str << "hd" if features.hd? + str << "subtitles" if features.subtitles? + str << "commons" if features.c_commons? + str << "360" if features.three_sixty? + str << "vr180" if features.vr180? + str << "3d" if features.three_d? + str << "hdr" if features.hdr? + str << "location" if features.location? + str << "purchased" if features.purchased? + + return str.join(',') + end + + def self.from_legacy_filters(str : String) : {Filters, String, String, Bool} + # Split search query on spaces + members = str.split(' ') + + # Output variables + channel = "" + filters = Filters.new + subscriptions = false + + # Array to hold the non-filter members + query = [] of String + + # Parse! + members.each do |substr| + # Separator operators + operators = substr.split(':') + + case operators[0] + when "user", "channel" + next if operators.size != 2 + channel = operators[1] + # + when "type", "content_type" + next if operators.size != 2 + type = Type.parse?(operators[1]) + filters.type = type if !type.nil? + # + when "date" + next if operators.size != 2 + date = Date.parse?(operators[1]) + filters.date = date if !date.nil? + # + when "duration" + next if operators.size != 2 + duration = Duration.parse?(operators[1]) + filters.duration = duration if !duration.nil? + # + when "feature", "features" + next if operators.size != 2 + features = parse_features(operators[1].split(',')) + filters.features = features if !features.nil? + # + when "sort" + next if operators.size != 2 + sort = Sort.parse?(operators[1]) + filters.sort = sort if !sort.nil? + # + when "subscriptions" + next if operators.size != 2 + subscriptions = {"true", "on", "yes", "1"}.any?(&.== operators[1]) + # + else + query << substr + end + end + + # Re-assemble query (without filters) + cleaned_query = query.join(' ') + + return {filters, channel, cleaned_query, subscriptions} + end + # ------------------- # Youtube params # ------------------- -- cgit v1.2.3 From fb2a331f79fcc42ac2c17ea349943ab2ba6ad0fe Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 6 Mar 2022 02:27:45 +0100 Subject: Add a function to parse search filters from invidious URL params --- spec/invidious/search/iv_filters_spec.cr | 79 ++++++++++++++++++++++++++++++++ src/invidious/search/filters.cr | 36 +++++++++++++++ 2 files changed, 115 insertions(+) (limited to 'src') diff --git a/spec/invidious/search/iv_filters_spec.cr b/spec/invidious/search/iv_filters_spec.cr index 6e8e6f3d..ebf01719 100644 --- a/spec/invidious/search/iv_filters_spec.cr +++ b/spec/invidious/search/iv_filters_spec.cr @@ -175,4 +175,83 @@ Spectator.describe Invidious::Search::Filters do expect(subs).to be_false end end + + # ------------------- + # Decode (URL) + # ------------------- + + describe "#from_iv_params" do + it "Decodes type= filter" do + Invidious::Search::Filters::Type.each do |value| + params = HTTP::Params.parse("type=#{value}") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(type: value)) + end + end + + it "Decodes date= filter" do + Invidious::Search::Filters::Date.each do |value| + params = HTTP::Params.parse("date=#{value}") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(date: value)) + end + end + + it "Decodes duration= filter" do + Invidious::Search::Filters::Duration.each do |value| + params = HTTP::Params.parse("duration=#{value}") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(duration: value)) + end + end + + it "Decodes features= filter (single)" do + Invidious::Search::Filters::Features.each do |value| + string = described_class.format_features(value) + params = HTTP::Params.parse("features=#{string}") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(features: value)) + end + end + + it "Decodes features= filter (multiple - comma separated)" do + features = Invidious::Search::Filters::Features.flags(HDR, VR180, CCommons) + params = HTTP::Params.parse("features=vr180%2Ccc%2Chdr") # %2C is a comma + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(features: features)) + end + + it "Decodes features= filter (multiple - URL parameters)" do + features = Invidious::Search::Filters::Features.flags(ThreeSixty, HD, FourK) + params = HTTP::Params.parse("features=4k&features=360&features=hd") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(features: features)) + end + + it "Decodes sort= filter" do + Invidious::Search::Filters::Sort.each do |value| + params = HTTP::Params.parse("sort=#{value}") + + expect(described_class.from_iv_params(params)) + .to eq(described_class.new(sort: value)) + end + end + + it "Ignores junk data" do + params = HTTP::Params.parse("foo=bar&sort=views&answer=42&type=channel") + + expect(described_class.from_iv_params(params)).to eq( + described_class.new( + sort: Invidious::Search::Filters::Sort::Views, + type: Invidious::Search::Filters::Type::Channel + ) + ) + end + end end diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr index c5e91aae..d7154d21 100644 --- a/src/invidious/search/filters.cr +++ b/src/invidious/search/filters.cr @@ -192,6 +192,42 @@ module Invidious::Search return {filters, channel, cleaned_query, subscriptions} end + def self.from_iv_params(params : HTTP::Params) : Filters + # Temporary variables + filters = Filters.new + + if type = params["type"]? + filters.type = Type.parse?(type) || Type::All + params.delete("type") + end + + if date = params["date"]? + filters.date = Date.parse?(date) || Date::None + params.delete("date") + end + + if duration = params["duration"]? + filters.duration = Duration.parse?(duration) || Duration::None + params.delete("duration") + end + + features = params.fetch_all("features") + if !features.empty? + # Un-array input so it can be treated as a comma-separated list + features = features[0].split(',') if features.size == 1 + + filters.features = parse_features(features) || Features::None + params.delete_all("features") + end + + if sort = params["sort"]? + filters.sort = Sort.parse?(sort) || Sort::Relevance + params.delete("sort") + end + + return filters + end + # ------------------- # Youtube params # ------------------- -- cgit v1.2.3 From 6991d0851fae9d9abff1a714a5bd72ccaac7dec4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 12 Mar 2022 19:56:52 +0100 Subject: Add a function to generate HTTP::Params from Filters --- spec/invidious/search/iv_filters_spec.cr | 114 +++++++++++++++++++++++++++++++ src/invidious/search/filters.cr | 19 ++++++ 2 files changed, 133 insertions(+) (limited to 'src') diff --git a/spec/invidious/search/iv_filters_spec.cr b/spec/invidious/search/iv_filters_spec.cr index ebf01719..b0897a63 100644 --- a/spec/invidious/search/iv_filters_spec.cr +++ b/spec/invidious/search/iv_filters_spec.cr @@ -254,4 +254,118 @@ Spectator.describe Invidious::Search::Filters do ) end end + + # ------------------- + # Encode (URL) + # ------------------- + + describe "#to_iv_params" do + it "Encodes date filter" do + Invidious::Search::Filters::Date.each do |value| + filters = described_class.new(date: value) + params = filters.to_iv_params + + if value.none? + expect("#{params}").to eq("") + else + expect("#{params}").to eq("date=#{value.to_s.underscore}") + end + end + end + + it "Encodes type filter" do + Invidious::Search::Filters::Type.each do |value| + filters = described_class.new(type: value) + params = filters.to_iv_params + + if value.all? + expect("#{params}").to eq("") + else + expect("#{params}").to eq("type=#{value.to_s.underscore}") + end + end + end + + it "Encodes duration filter" do + Invidious::Search::Filters::Duration.each do |value| + filters = described_class.new(duration: value) + params = filters.to_iv_params + + if value.none? + expect("#{params}").to eq("") + else + expect("#{params}").to eq("duration=#{value.to_s.underscore}") + end + end + end + + it "Encodes features filter (single)" do + Invidious::Search::Filters::Features.each do |value| + string = described_class.format_features(value) + filters = described_class.new(features: value) + + expect("#{filters.to_iv_params}") + .to eq("features=" + FEATURES_TEXT[value]) + end + end + + it "Encodes features filter (multiple)" do + features = Invidious::Search::Filters::Features.flags(Subtitles, Live, ThreeSixty) + filters = described_class.new(features: features) + + expect("#{filters.to_iv_params}") + .to eq("features=live%2Csubtitles%2C360") # %2C is a comma + end + + it "Encodes sort filter" do + Invidious::Search::Filters::Sort.each do |value| + filters = described_class.new(sort: value) + params = filters.to_iv_params + + if value.relevance? + expect("#{params}").to eq("") + else + expect("#{params}").to eq("sort=#{value.to_s.underscore}") + end + end + end + + it "Encodes multiple filters" do + filters = described_class.new( + date: Invidious::Search::Filters::Date::Today, + duration: Invidious::Search::Filters::Duration::Medium, + features: Invidious::Search::Filters::Features.flags(Location, Purchased), + sort: Invidious::Search::Filters::Sort::Relevance + ) + + params = filters.to_iv_params + + # Check the `date` param + expect(params).to have_key("date") + expect(params.fetch_all("date")).to contain_exactly("today") + + # Check the `type` param + expect(params).to_not have_key("type") + expect(params["type"]?).to be_nil + + # Check the `duration` param + expect(params).to have_key("duration") + expect(params.fetch_all("duration")).to contain_exactly("medium") + + # Check the `features` param + expect(params).to have_key("features") + expect(params.fetch_all("features")).to contain_exactly("location,purchased") + + # Check the `sort` param + expect(params).to_not have_key("sort") + expect(params["sort"]?).to be_nil + + # Check if there aren't other parameters + params.delete("date") + params.delete("duration") + params.delete("features") + + expect(params).to be_empty + end + end end diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr index d7154d21..8f4ada6c 100644 --- a/src/invidious/search/filters.cr +++ b/src/invidious/search/filters.cr @@ -228,6 +228,25 @@ module Invidious::Search return filters end + def to_iv_params : HTTP::Params + # Temporary variables + raw_params = {} of String => Array(String) + + raw_params["date"] = [@date.to_s.underscore] if !@date.none? + raw_params["type"] = [@type.to_s.underscore] if !@type.all? + raw_params["sort"] = [@sort.to_s.underscore] if !@sort.relevance? + + if !@duration.none? + raw_params["duration"] = [@duration.to_s.underscore] + end + + if !@features.none? + raw_params["features"] = [Filters.format_features(@features)] + end + + return HTTP::Params.new(raw_params) + end + # ------------------- # Youtube params # ------------------- -- cgit v1.2.3 From c152243b4d69191bcd672adbb55e7f7fb3c3ee2a Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Wed, 30 Mar 2022 19:52:39 +0200 Subject: new method for bypassing age restriction (#2996) --- src/invidious/videos.cr | 4 ++-- src/invidious/yt_backend/youtube_api.cr | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 81fce5b8..b50e7b2c 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -585,7 +585,7 @@ struct Video def allowed_regions info - .dig("microformat", "playerMicroformatRenderer", "availableCountries") + .dig?("microformat", "playerMicroformatRenderer", "availableCountries") .try &.as_a.map &.as_s || [] of String end @@ -876,7 +876,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) if context_screen == "embed" - client_config.client_type = YoutubeAPI::ClientType::WebScreenEmbed + client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed end player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index d1b52a5a..2678ac6c 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -14,6 +14,7 @@ module YoutubeAPI Android AndroidEmbeddedPlayer AndroidScreenEmbed + TvHtml5ScreenEmbed end # List of hard-coded values used by the different clients @@ -60,6 +61,12 @@ module YoutubeAPI api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "EMBED", }, + ClientType::TvHtml5ScreenEmbed => { + name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", + version: "2.0", + api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + screen: "EMBED", + }, } #################################################################### -- cgit v1.2.3 From 1e3425fdee91f1b25f67d1e03872b68e978bc6e0 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 9 Mar 2022 22:21:53 +0100 Subject: Add filters UI HTML generator --- assets/css/search.css | 91 +++++++++++++++++++++ locales/en-US.json | 69 +++++++++------- src/invidious/frontend/search_filters.cr | 135 +++++++++++++++++++++++++++++++ 3 files changed, 264 insertions(+), 31 deletions(-) create mode 100644 assets/css/search.css create mode 100644 src/invidious/frontend/search_filters.cr (limited to 'src') diff --git a/assets/css/search.css b/assets/css/search.css new file mode 100644 index 00000000..ad2b0b16 --- /dev/null +++ b/assets/css/search.css @@ -0,0 +1,91 @@ +summary { + /* This should hide the marker */ + display: block; + + font-size: 1.17em; + font-weight: bold; + margin: 0 auto 10px auto; +} + +summary::-webkit-details-marker, +summary::marker { display: none; } + +summary:before { + border-radius: 5px; + content: "[ + ]"; + margin: -2px 10px 0 10px; + padding: 1px 0 3px 0; + text-align: center; + width: 40px; +} + +details[open] > summary:before { content: "[ ‒ ]"; } + + +#filters-box { + background: #373737; + padding: 10px 20px 20px 10px; + margin: 10px 15px; +} +#filters-flex { + display: flex; + flex-wrap: wrap; + flex-direction: row; + align-items: flex-start; + align-content: flex-start; + justify-content: flex-start; +} + + +fieldset, legend { + display: contents !important; + border: none !important; + margin: 0 !important; + padding: 0 !important; +} + + +.filter-column { + display: inline-block; + display: inline-flex; + width: max-content; + min-width: max-content; + max-width: 16em; + margin: 15px; + flex-grow: 2; + flex-basis: auto; + flex-direction: column; +} +.filter-name, .filter-options { + display: block; + padding: 5px 10px; + margin: 0; + text-align: start; +} + +/* TODO: move that to the main file */ +.underlined { + border-bottom: 1px solid; + margin-bottom: 20px; +} + + +.filter-options div { margin: 6px 0; } +.filter-options div * { vertical-align: middle; } +.filter-options label { margin: 0 10px; } + + +#filters-apply { text-align: end; } + + +@media only screen and (max-width: 800px) { + summary { font-size: 1.30em; } + #filters-box { + margin: 10px 0 0 0; + padding: 0; + } + #filters-apply { + text-align: center; + padding: 15px; + } +} diff --git a/locales/en-US.json b/locales/en-US.json index a78d8062..03df88b6 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -404,37 +404,44 @@ "Videos": "Videos", "Playlists": "Playlists", "Community": "Community", - "relevance": "Relevance", - "rating": "Rating", - "date": "Upload date", - "views": "View count", - "content_type": "Type", - "duration": "Duration", - "features": "Features", - "sort": "Sort By", - "hour": "Last Hour", - "today": "Today", - "week": "This week", - "month": "This month", - "year": "This year", - "video": "Video", - "channel": "Channel", - "playlist": "Playlist", - "movie": "Movie", - "show": "Show", - "short": "Short (< 4 minutes)", - "long": "Long (> 20 minutes)", - "hd": "HD", - "subtitles": "Subtitles/CC", - "creative_commons": "Creative Commons", - "3d": "3D", - "live": "Live", - "4k": "4K", - "location": "Location", - "hdr": "HDR", - "purchased": "Purchased", - "360": "360°", - "filter": "Filter", + "search_filters_title": "Filters", + "search_filters_date_label": "Upload date", + "search_filters_date_option_none": "Any date", + "search_filters_date_option_hour": "Last Hour", + "search_filters_date_option_today": "Today", + "search_filters_date_option_week": "This week", + "search_filters_date_option_month": "This month", + "search_filters_date_option_year": "This year", + "search_filters_type_label": "Type", + "search_filters_type_option_all": "Any type", + "search_filters_type_option_video": "Video", + "search_filters_type_option_channel": "Channel", + "search_filters_type_option_playlist": "Playlist", + "search_filters_type_option_movie": "Movie", + "search_filters_type_option_show": "Show", + "search_filters_duration_label": "Duration", + "search_filters_duration_option_none": "Any duration", + "search_filters_duration_option_short": "Short (< 4 minutes)", + "search_filters_duration_option_medium": "Medium (4 - 20 minutes)", + "search_filters_duration_option_long": "Long (> 20 minutes)", + "search_filters_features_label": "Features", + "search_filters_features_option_live": "Live", + "search_filters_features_option_four_k": "4K", + "search_filters_features_option_hd": "HD", + "search_filters_features_option_subtitles": "Subtitles/CC", + "search_filters_features_option_c_commons": "Creative Commons", + "search_filters_features_option_three_sixty": "360°", + "search_filters_features_option_vr180": "VR180", + "search_filters_features_option_three_d": "3D", + "search_filters_features_option_hdr": "HDR", + "search_filters_features_option_location": "Location", + "search_filters_features_option_purchased": "Purchased", + "search_filters_sort_label": "Sort By", + "search_filters_sort_option_relevance": "Relevance", + "search_filters_sort_option_rating": "Rating", + "search_filters_sort_option_date": "Upload Date", + "search_filters_sort_option_views": "View count", + "search_filters_apply_button": "Apply selected filters", "Current version: ": "Current version: ", "next_steps_error_message": "After which you should try to: ", "next_steps_error_message_refresh": "Refresh", diff --git a/src/invidious/frontend/search_filters.cr b/src/invidious/frontend/search_filters.cr new file mode 100644 index 00000000..68f27b4f --- /dev/null +++ b/src/invidious/frontend/search_filters.cr @@ -0,0 +1,135 @@ +module Invidious::Frontend::SearchFilters + extend self + + # Generate the search filters collapsable widget. + def generate(filters : Search::Filters, query : String, page : Int, locale : String) : String + return String.build(8000) do |str| + str << "
    \n" + str << "\t
    " + str << "\t\t" << translate(locale, "search_filters_title") << "\n" + + str << "\t\t
    \n" + + str << "\t\t\t\n" + str << "\t\t\t\n" + + str << "\t\t\t
    " + + filter_wrapper(date) + filter_wrapper(type) + filter_wrapper(duration) + filter_wrapper(features) + filter_wrapper(sort) + + str << "\t\t\t
    \n" + + str << "\t\t\t
    " + str << "
    \n" + + str << "\t\t
    \n" + + str << "\t
    \n" + str << "
    \n" + end + end + + # Generate wrapper HTML (`
    `, filter name, etc...) around the + # `` elements of a search filter + macro filter_wrapper(name) + str << "\t\t\t\t
    \n" + + str << "\t\t\t\t\t
    " + str << translate(locale, "search_filters_{{name}}_label") + str << "
    \n" + + str << "\t\t\t\t\t
    \n" + make_{{name}}_filter_options(str, filters.{{name}}, locale) + str << "\t\t\t\t\t
    " + + str << "\t\t\t\t
    \n" + end + + # Generates the HTML for the list of radio buttons of the "date" search filter + def make_date_filter_options(str : String::Builder, value : Search::Filters::Date, locale : String) + {% for value in Invidious::Search::Filters::Date.constants %} + {% date = value.underscore %} + + str << "\t\t\t\t\t\t
    " + str << "' + + str << "
    \n" + {% end %} + end + + # Generates the HTML for the list of radio buttons of the "type" search filter + def make_type_filter_options(str : String::Builder, value : Search::Filters::Type, locale : String) + {% for value in Invidious::Search::Filters::Type.constants %} + {% type = value.underscore %} + + str << "\t\t\t\t\t\t
    " + str << "' + + str << "
    \n" + {% end %} + end + + # Generates the HTML for the list of radio buttons of the "duration" search filter + def make_duration_filter_options(str : String::Builder, value : Search::Filters::Duration, locale : String) + {% for value in Invidious::Search::Filters::Duration.constants %} + {% duration = value.underscore %} + + str << "\t\t\t\t\t\t
    " + str << "' + + str << "
    \n" + {% end %} + end + + # Generates the HTML for the list of checkboxes of the "features" search filter + def make_features_filter_options(str : String::Builder, value : Search::Filters::Features, locale : String) + {% for value in Invidious::Search::Filters::Features.constants %} + {% if value.stringify != "All" && value.stringify != "None" %} + {% feature = value.underscore %} + + str << "\t\t\t\t\t\t
    " + str << "' + + str << "
    \n" + {% end %} + {% end %} + end + + # Generates the HTML for the list of radio buttons of the "sort" search filter + def make_sort_filter_options(str : String::Builder, value : Search::Filters::Sort, locale : String) + {% for value in Invidious::Search::Filters::Sort.constants %} + {% sort = value.underscore %} + + str << "\t\t\t\t\t\t
    " + str << "' + + str << "
    \n" + {% end %} + end +end -- cgit v1.2.3 From a813955ad39c254240dcb02344d94d03d0bbd6b2 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 26 Mar 2022 22:18:42 +0100 Subject: Add Search::Query class to handle search queries --- src/invidious/search/filters.cr | 5 ++ src/invidious/search/query.cr | 149 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 src/invidious/search/query.cr (limited to 'src') diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr index 8f4ada6c..0e8438b9 100644 --- a/src/invidious/search/filters.cr +++ b/src/invidious/search/filters.cr @@ -79,6 +79,11 @@ module Invidious::Search ) end + def is_default? : Bool + return @date.none? && @type.all? && @duration.none? && \ + @features.none? && @sort.relevance? + end + # ------------------- # Invidious params # ------------------- diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr new file mode 100644 index 00000000..4d76b083 --- /dev/null +++ b/src/invidious/search/query.cr @@ -0,0 +1,149 @@ +module Invidious::Search + class Query + enum Type + # Types related to YouTube + Regular # Youtube search page + Channel # Youtube channel search box + + # Types specific to Invidious + Subscriptions # Search user subscriptions + Playlist # "Add playlist item" search + end + + @type : Type = Type::Regular + + @raw_query : String + @query : String = "" + + property filters : Filters = Filters.new + property page : Int32 + property region : String? + property channel : String = "" + + # Return true if @raw_query is either `nil` or empty + private def empty_raw_query? + return @raw_query.empty? + end + + # Same as `empty_raw_query?`, but named for external use + def empty? + return self.empty_raw_query? + end + + # Getter for the query string. + # It is named `text` to reduce confusion (`search_query.text` makes more + # sense than `search_query.query`) + def text + return @query + end + + # Initialize a new search query. + # Parameters are used to get the query string, the page number + # and the search filters (if any). Type tells this function + # where it is being called from (See `Type` above). + def initialize( + params : HTTP::Params, + @type : Type = Type::Regular, + @region : String? = nil + ) + # Get the raw search query string (common to all search types). In + # Regular search mode, also look for the `search_query` URL parameter + if @type.regular? + @raw_query = params["q"]? || params["search_query"]? || "" + else + @raw_query = params["q"]? || "" + end + + # Get the page number (also common to all search types) + @page = params["page"]?.try &.to_i? || 1 + + # Stop here is raw query in empty + # NOTE: maybe raise in the future? + return if self.empty_raw_query? + + # Specific handling + case @type + when .playlist?, .channel? + # In "add playlist item" mode, filters are parsed from the query + # string itself (legacy), and the channel is ignored. + # + # In "channel search" mode, filters are ignored, but we still parse + # the query prevent transmission of legacy filters to youtube. + # + @filters, @query, @channel, _ = Filters.from_legacy_filters(@raw_query || "") + # + when .subscriptions?, .regular? + if params["sp"]? + # Parse the `sp` URL parameter (youtube compatibility) + @filters = Filters.from_yt_params(params) + @query = @raw_query || "" + else + # Parse invidious URL parameters (sort, date, etc...) + @filters = Filters.from_iv_params(params) + @channel = params["channel"]? || "" + + if @filters.default? && @raw_query.includes?(':') + # Parse legacy filters from query + @filters, @query, @channel, subs = Filters.from_legacy_filters(@raw_query || "") + else + @query = @raw_query || "" + end + + if !@channel.empty? + # Switch to channel search mode (filters will be ignored) + @type = Type::Channel + elsif subs + # Switch to subscriptions search mode + @type = Type::Subscriptions + end + end + end + end + + # Run the search query using the corresponding search processor. + # Returns either the results or an empty array of `SearchItem`. + def process(user : Invidious::User? = nil) : Array(SearchItem) | Array(ChannelVideo) + items = [] of SearchItem + + # Don't bother going further if search query is empty + return items if self.empty_raw_query? + + case @type + when .regular?, .playlist? + all_items = search(@query, @filters, @page, @region) + items = unnest_items(all_items) + # + when .channel? + items = Processors.channel(@query, @page, @channel) + # + when .subscriptions? + if user + items = Processors.subscriptions(self, user.as(Invidious::User)) + end + end + + return items + end + + # TODO: clean code + private def unnest_items(all_items) : Array(SearchItem) + items = [] of SearchItem + + # Light processing to flatten search results out of Categories. + # They should ideally be supported in the future. + all_items.each do |i| + if i.is_a? Category + i.contents.each do |nest_i| + if !nest_i.is_a? Video + items << nest_i + end + end + else + items << i + end + end + + return items + end + end +end -- cgit v1.2.3 From d93a7b315db42474aac4a8e27c3745dc4b5abdeb Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 26 Mar 2022 20:15:02 +0100 Subject: Make use of Search::Query/Filters and associated HTML generator --- src/invidious/routes/api/v1/channels.cr | 16 +-- src/invidious/routes/api/v1/search.cr | 24 +---- src/invidious/routes/playlists.cr | 18 ++-- src/invidious/routes/search.cr | 24 ++--- src/invidious/search.cr | 150 +---------------------------- src/invidious/search/filters.cr | 2 +- src/invidious/search/processors.cr | 28 ++++-- src/invidious/search/query.cr | 5 +- src/invidious/views/add_playlist_items.ecr | 11 ++- src/invidious/views/search.ecr | 140 +++++---------------------- 10 files changed, 87 insertions(+), 331 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index c4395353..8650976d 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -251,18 +251,22 @@ module Invidious::Routes::API::V1::Channels def self.search(env) locale = env.get("preferences").as(Preferences).locale + region = env.params.query["region"]? env.response.content_type = "application/json" - ucid = env.params.url["ucid"] + query = Invidious::Search::Query.new(env.params.query, :channel, region) - query = env.params.query["q"]? - query ||= "" + # Required because we can't (yet) pass multiple parameter to the + # `Search::Query` initializer (in this case, an URL segment) + query.channel = env.params.url["ucid"] - page = env.params.query["page"]?.try &.to_i? - page ||= 1 + begin + search_results = query.process + rescue ex + return error_json(400, ex) + end - search_results = Invidious::Search::Processors.channel(query, page, ucid) JSON.build do |json| json.array do search_results.each do |item| diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index 5666460d..21451d33 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -5,34 +5,14 @@ module Invidious::Routes::API::V1::Search env.response.content_type = "application/json" - query = env.params.query["q"]? - query ||= "" - - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "relevance" - - date = env.params.query["date"]?.try &.downcase - date ||= "" - - duration = env.params.query["duration"]?.try &.downcase - duration ||= "" - - features = env.params.query["features"]?.try &.split(",").map(&.downcase) - features ||= [] of String - - content_type = env.params.query["type"]?.try &.downcase - content_type ||= "video" + query = Invidious::Search::Query.new(env.params.query, :regular, region) begin - search_params = produce_search_params(page, sort_by, date, content_type, duration, features) + search_results = query.process rescue ex return error_json(400, ex) end - search_results = search(query, search_params, region) JSON.build do |json| json.array do search_results.each do |item| diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index dbeb4f97..de981d81 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -212,7 +212,10 @@ module Invidious::Routes::Playlists end def self.add_playlist_items_page(env) - locale = env.get("preferences").as(Preferences).locale + prefs = env.get("preferences").as(Preferences) + locale = prefs.locale + + region = env.params.query["region"]? || prefs.region user = env.get? "user" sid = env.get? "sid" @@ -236,15 +239,10 @@ module Invidious::Routes::Playlists return env.redirect referer end - query = env.params.query["q"]? - if query - begin - search_query, items, operators = process_search_query(query, page, user, region: nil) - videos = items.select(SearchVideo).map(&.as(SearchVideo)) - rescue ex - videos = [] of SearchVideo - end - else + begin + query = Invidious::Search::Query.new(env.params.query, :playlist, region) + videos = query.process.select(SearchVideo).map(&.as(SearchVideo)) + rescue ex videos = [] of SearchVideo end diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 3f4c7e5e..e60d0081 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -37,37 +37,29 @@ module Invidious::Routes::Search end def self.search(env) - locale = env.get("preferences").as(Preferences).locale - region = env.params.query["region"]? + prefs = env.get("preferences").as(Preferences) + locale = prefs.locale - query = env.params.query["search_query"]? - query ||= env.params.query["q"]? + region = env.params.query["region"]? || prefs.region + + query = Invidious::Search::Query.new(env.params.query, :regular, region) - if !query || query.empty? + if query.empty? # Display the full page search box implemented in #1977 env.set "search", "" templated "search_homepage", navbar_search: false else - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - user = env.get? "user" begin - search_query, videos, operators = process_search_query(query, page, user, region: region) + videos = query.process rescue ex : ChannelSearchException return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.") rescue ex return error_template(500, ex) end - operator_hash = {} of String => String - operators.each do |operator| - key, value = operator.downcase.split(":") - operator_hash[key] = value - end - - env.set "search", query + env.set "search", query.text templated "search" end end diff --git a/src/invidious/search.cr b/src/invidious/search.cr index af854653..e4c21bd4 100644 --- a/src/invidious/search.cr +++ b/src/invidious/search.cr @@ -5,113 +5,6 @@ class ChannelSearchException < InfoException end end -def search(query, search_params = produce_search_params(content_type: "all"), region = nil) : Array(SearchItem) - return [] of SearchItem if query.empty? - - client_config = YoutubeAPI::ClientConfig.new(region: region) - initial_data = YoutubeAPI.search(query, search_params, client_config: client_config) - - return extract_items(initial_data) -end - -def produce_search_params(page = 1, sort : String = "relevance", date : String = "", content_type : String = "", - duration : String = "", features : Array(String) = [] of String) - object = { - "1:varint" => 0_i64, - "2:embedded" => {} of String => Int64, - "9:varint" => ((page - 1) * 20).to_i64, - } - - case sort - when "relevance" - object["1:varint"] = 0_i64 - when "rating" - object["1:varint"] = 1_i64 - when "upload_date", "date" - object["1:varint"] = 2_i64 - when "view_count", "views" - object["1:varint"] = 3_i64 - else - raise "No sort #{sort}" - end - - case date - when "hour" - object["2:embedded"].as(Hash)["1:varint"] = 1_i64 - when "today" - object["2:embedded"].as(Hash)["1:varint"] = 2_i64 - when "week" - object["2:embedded"].as(Hash)["1:varint"] = 3_i64 - when "month" - object["2:embedded"].as(Hash)["1:varint"] = 4_i64 - when "year" - object["2:embedded"].as(Hash)["1:varint"] = 5_i64 - else nil # Ignore - end - - case content_type - when "video" - object["2:embedded"].as(Hash)["2:varint"] = 1_i64 - when "channel" - object["2:embedded"].as(Hash)["2:varint"] = 2_i64 - when "playlist" - object["2:embedded"].as(Hash)["2:varint"] = 3_i64 - when "movie" - object["2:embedded"].as(Hash)["2:varint"] = 4_i64 - when "show" - object["2:embedded"].as(Hash)["2:varint"] = 5_i64 - when "all" - # - else - object["2:embedded"].as(Hash)["2:varint"] = 1_i64 - end - - case duration - when "short" - object["2:embedded"].as(Hash)["3:varint"] = 1_i64 - when "long" - object["2:embedded"].as(Hash)["3:varint"] = 2_i64 - else nil # Ignore - end - - features.each do |feature| - case feature - when "hd" - object["2:embedded"].as(Hash)["4:varint"] = 1_i64 - when "subtitles" - object["2:embedded"].as(Hash)["5:varint"] = 1_i64 - when "creative_commons", "cc" - object["2:embedded"].as(Hash)["6:varint"] = 1_i64 - when "3d" - object["2:embedded"].as(Hash)["7:varint"] = 1_i64 - when "live", "livestream" - object["2:embedded"].as(Hash)["8:varint"] = 1_i64 - when "purchased" - object["2:embedded"].as(Hash)["9:varint"] = 1_i64 - when "4k" - object["2:embedded"].as(Hash)["14:varint"] = 1_i64 - when "360" - object["2:embedded"].as(Hash)["15:varint"] = 1_i64 - when "location" - object["2:embedded"].as(Hash)["23:varint"] = 1_i64 - when "hdr" - object["2:embedded"].as(Hash)["25:varint"] = 1_i64 - else nil # Ignore - end - end - - if object["2:embedded"].as(Hash).empty? - object.delete("2:embedded") - end - - params = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return params -end - def produce_channel_search_continuation(ucid, query, page) if page <= 1 idx = 0_i64 @@ -146,41 +39,10 @@ def produce_channel_search_continuation(ucid, query, page) end def process_search_query(query, page, user, region) - channel = nil - content_type = "all" - date = "" - duration = "" - features = [] of String - sort = "relevance" - subscriptions = nil - - operators = query.split(" ").select(&.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 + # Parse legacy query + filters, channel, search_query, subscriptions = Invidious::Search::Filters.from_legacy_filters(query) - search_query = (query.split(" ") - operators).join(" ") - - if channel + if !channel.nil? && !channel.empty? items = Invidious::Search::Processors.channel(search_query, page, channel) elsif subscriptions if user @@ -190,9 +52,7 @@ def process_search_query(query, page, user, region) items = [] of ChannelVideo end else - search_params = produce_search_params(page: page, sort: sort, date: date, content_type: content_type, - duration: duration, features: features) - + search_params = filters.to_yt_params(page: page) items = search(search_query, search_params, region) end @@ -211,5 +71,5 @@ def process_search_query(query, page, user, region) end end - {search_query, items_without_category, operators} + {search_query, items_without_category, filters} end diff --git a/src/invidious/search/filters.cr b/src/invidious/search/filters.cr index 0e8438b9..c2b5c758 100644 --- a/src/invidious/search/filters.cr +++ b/src/invidious/search/filters.cr @@ -79,7 +79,7 @@ module Invidious::Search ) end - def is_default? : Bool + def default? : Bool return @date.none? && @type.all? && @duration.none? && \ @features.none? && @sort.relevance? end diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr index c5327f34..d1409c06 100644 --- a/src/invidious/search/processors.cr +++ b/src/invidious/search/processors.cr @@ -2,22 +2,32 @@ module Invidious::Search module Processors extend self + # Regular search (`/search` endpoint) + def regular(query : Query) : Array(SearchItem) + search_params = query.filters.to_yt_params(page: query.page) + + client_config = YoutubeAPI::ClientConfig.new(region: query.region) + initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config) + + return extract_items(initial_data) + end + # Search a youtube channel # TODO: clean code, and rely more on YoutubeAPI - def channel(query, page, channel) : Array(SearchItem) - response = YT_POOL.client &.get("/channel/#{channel}") + def channel(query : Query) : Array(SearchItem) + response = YT_POOL.client &.get("/channel/#{query.channel}") if response.status_code == 404 - response = YT_POOL.client &.get("/user/#{channel}") - response = YT_POOL.client &.get("/c/#{channel}") if response.status_code == 404 + response = YT_POOL.client &.get("/user/#{query.channel}") + response = YT_POOL.client &.get("/c/#{query.channel}") if response.status_code == 404 initial_data = extract_initial_data(response.body) ucid = initial_data.dig?("header", "c4TabbedHeaderRenderer", "channelId").try(&.as_s?) - raise ChannelSearchException.new(channel) if !ucid + raise ChannelSearchException.new(query.channel) if !ucid else - ucid = channel + ucid = query.channel end - continuation = produce_channel_search_continuation(ucid, query, page) + continuation = produce_channel_search_continuation(ucid, query.text, query.page) response_json = YoutubeAPI.browse(continuation) continuation_items = response_json["onResponseReceivedActions"]? @@ -34,7 +44,7 @@ module Invidious::Search end # Search inside of user subscriptions - def subscriptions(query, page, user : Invidious::User) : Array(ChannelVideo) + def subscriptions(query : Query, user : Invidious::User) : Array(ChannelVideo) view_name = "subscriptions_#{sha256(user.email)}" return PG_DB.query_all(" @@ -46,7 +56,7 @@ module Invidious::Search as document FROM #{view_name} ) v_search WHERE v_search.document @@ plainto_tsquery($1) LIMIT 20 OFFSET $2;", - query, (page - 1) * 20, + query.text, (query.page - 1) * 20, as: ChannelVideo ) end diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr index 4d76b083..1c2b37d2 100644 --- a/src/invidious/search/query.cr +++ b/src/invidious/search/query.cr @@ -110,11 +110,10 @@ module Invidious::Search case @type when .regular?, .playlist? - all_items = search(@query, @filters, @page, @region) - items = unnest_items(all_items) + items = unnest_items(Processors.regular(self)) # when .channel? - items = Processors.channel(@query, @page, @channel) + items = Processors.channel(self) # when .subscriptions? if user diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index ad50909a..22870317 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -11,7 +11,9 @@ <%= translate(locale, "Editing playlist `x`", %|"#{HTML.escape(playlist.title)}"|) %>
    - value="<%= HTML.escape(query) %>"<% else %>placeholder="<%= translate(locale, "Search for videos") %>"<% end %>> + value="<%= HTML.escape(query.text) %>"<% end %> + placeholder="<%= translate(locale, "Search for videos") %>">
    @@ -38,10 +40,11 @@
    <% if query %> + <%- query_encoded = URI.encode_www_form(query.text, space_to_plus: true) -%>
    - <% if page > 1 %> - + <% if query.page > 1 %> + <%= translate(locale, "Previous page") %> <% end %> @@ -49,7 +52,7 @@
    <% if videos.size >= 20 %> - + <%= translate(locale, "Next page") %> <% end %> diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index 45bbdefc..f1f6ab20 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -1,124 +1,38 @@ <% content_for "header" do %> -<%= search_query.not_nil!.size > 30 ? HTML.escape(query.not_nil![0,30].rstrip(".") + "...") : HTML.escape(query.not_nil!) %> - Invidious +<%= query.text.size > 30 ? HTML.escape(query.text[0,30].rstrip(".")) + "…" : HTML.escape(query.text) %> - Invidious + <% end %> -<% search_query_encoded = env.get?("search").try { |x| URI.encode_www_form(x.as(String), space_to_plus: true) } %> +<%- + search_query_encoded = URI.encode_www_form(query.text, space_to_plus: true) + filter_params = query.filters.to_iv_params + + url_prev_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page - 1}" + url_next_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page + 1}" +-%> <% if videos.size == 0 %>

    "><%= translate(locale, "Broken? Try another Invidious Instance!") %>

    -<% else %> -
    - -

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

    -
    -
    -
    - <%= translate(locale, "date") %> -
    - <% ["hour", "today", "week", "month", "year"].each do |date| %> -
    - <% if operator_hash.fetch("date", "all") == date %> - <%= translate(locale, date) %> - <% else %> - &page=<%= page %>"> - <%= translate(locale, date) %> - - <% end %> -
    - <% end %> -
    -
    - <%= translate(locale, "content_type") %> -
    - <% ["video", "channel", "playlist", "movie", "show"].each do |content_type| %> -
    - <% if operator_hash.fetch("content_type", "all") == content_type %> - <%= translate(locale, content_type) %> - <% else %> - &page=<%= page %>"> - <%= translate(locale, content_type) %> - - <% end %> -
    - <% end %> -
    -
    - <%= translate(locale, "duration") %> -
    - <% ["short", "long"].each do |duration| %> -
    - <% if operator_hash.fetch("duration", "all") == duration %> - <%= translate(locale, duration) %> - <% else %> - &page=<%= page %>"> - <%= translate(locale, duration) %> - - <% end %> -
    - <% end %> -
    -
    - <%= translate(locale, "features") %> -
    - <% ["hd", "subtitles", "creative_commons", "3d", "live", "purchased", "4k", "360", "location", "hdr"].each do |feature| %> -
    - <% if operator_hash.fetch("features", "all").includes?(feature) %> - <%= translate(locale, feature) %> - <% elsif operator_hash.has_key?("features") %> - &page=<%= page %>"> - <%= translate(locale, feature) %> - - <% else %> - &page=<%= page %>"> - <%= translate(locale, feature) %> - - <% end %> -
    - <% end %> -
    -
    - <%= translate(locale, "sort") %> -
    - <% ["relevance", "rating", "date", "views"].each do |sort| %> -
    - <% if operator_hash.fetch("sort", "relevance") == sort %> - <%= translate(locale, sort) %> - <% else %> - &page=<%= page %>"> - <%= translate(locale, sort) %> - - <% end %> -
    - <% end %> -
    -
    -
    -<% end %> +<%- else -%> + <%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %> +<%- end -%> -<% if videos.size == 0 %> -
    -<% else %> -
    -<% end %> +<% if videos.size == 0 %>
    <% else %>
    <% end %>
    - <% if page > 1 %> - - <%= translate(locale, "Previous page") %> - - <% end %> + <%- if query.page > 1 -%> + <%= translate(locale, "Previous page") %> + <%- end -%>
    - <% if videos.size >= 20 %> - - <%= translate(locale, "Next page") %> - - <% end %> + <%- if videos.size >= 20 -%> + <%= translate(locale, "Next page") %> + <%- end -%>
    @@ -130,18 +44,14 @@
    - <% if page > 1 %> - - <%= translate(locale, "Previous page") %> - - <% end %> + <%- if query.page > 1 -%> + <%= translate(locale, "Previous page") %> + <%- end -%>
    - <% if videos.size >= 20 %> - - <%= translate(locale, "Next page") %> - - <% end %> + <%- if videos.size >= 20 -%> + <%= translate(locale, "Next page") %> + <%- end -%>
    -- cgit v1.2.3 From af029177666486d7524a7d1fae03aed91b58e556 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 27 Mar 2022 01:20:00 +0100 Subject: Code cleanup --- .ameba.yml | 4 --- spec/spec_helper.cr | 2 +- src/invidious/exceptions.cr | 8 +++++ src/invidious/search.cr | 75 ------------------------------------------ src/invidious/search/ctoken.cr | 32 ++++++++++++++++++ 5 files changed, 41 insertions(+), 80 deletions(-) delete mode 100644 src/invidious/search.cr create mode 100644 src/invidious/search/ctoken.cr (limited to 'src') diff --git a/.ameba.yml b/.ameba.yml index 247705e8..96cbc8f0 100644 --- a/.ameba.yml +++ b/.ameba.yml @@ -77,10 +77,6 @@ Metrics/CyclomaticComplexity: # process_video_params(query, preferences) => [20/10] - src/invidious/videos.cr - # produce_search_params(page, sort, ...) => [29/10] - # process_search_query(query, page, ...) => [14/10] - - src/invidious/search.cr - #src/invidious/playlists.cr:327:5 diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 09320750..6c492e2f 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -8,7 +8,7 @@ require "../src/invidious/channels/*" require "../src/invidious/videos" require "../src/invidious/comments" require "../src/invidious/playlists" -require "../src/invidious/search" +require "../src/invidious/search/ctoken" require "../src/invidious/trending" require "spectator" diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index 490d98cd..bfaa3fd5 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -1,3 +1,11 @@ +# Exception used to hold the bogus UCID during a channel search. +class ChannelSearchException < InfoException + getter channel : String + + def initialize(@channel) + end +end + # Exception used to hold the name of the missing item # Should be used in all parsing functions class BrokenTubeException < Exception diff --git a/src/invidious/search.cr b/src/invidious/search.cr deleted file mode 100644 index e4c21bd4..00000000 --- a/src/invidious/search.cr +++ /dev/null @@ -1,75 +0,0 @@ -class ChannelSearchException < InfoException - getter channel : String - - def initialize(@channel) - end -end - -def produce_channel_search_continuation(ucid, query, page) - if page <= 1 - idx = 0_i64 - else - idx = 30_i64 * (page - 1) - end - - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "search", - "6:varint" => 1_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "15:base64" => { - "3:varint" => idx, - }, - "23:varint" => 0_i64, - }, - "11:string" => query, - "35:string" => "browse-feed#{ucid}search", - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end - -def process_search_query(query, page, user, region) - # Parse legacy query - filters, channel, search_query, subscriptions = Invidious::Search::Filters.from_legacy_filters(query) - - if !channel.nil? && !channel.empty? - items = Invidious::Search::Processors.channel(search_query, page, channel) - elsif subscriptions - if user - user = user.as(Invidious::User) - items = Invidious::Search::Processors.subscriptions(query, page, user) - else - items = [] of ChannelVideo - end - else - search_params = filters.to_yt_params(page: page) - items = search(search_query, search_params, region) - end - - # Light processing to flatten search results out of Categories. - # They should ideally be supported in the future. - items_without_category = [] of SearchItem | ChannelVideo - items.each do |i| - if i.is_a? Category - i.contents.each do |nest_i| - if !nest_i.is_a? Video - items_without_category << nest_i - end - end - else - items_without_category << i - end - end - - {search_query, items_without_category, filters} -end diff --git a/src/invidious/search/ctoken.cr b/src/invidious/search/ctoken.cr new file mode 100644 index 00000000..161065e0 --- /dev/null +++ b/src/invidious/search/ctoken.cr @@ -0,0 +1,32 @@ +def produce_channel_search_continuation(ucid, query, page) + if page <= 1 + idx = 0_i64 + else + idx = 30_i64 * (page - 1) + end + + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:base64" => { + "2:string" => "search", + "6:varint" => 1_i64, + "7:varint" => 1_i64, + "12:varint" => 1_i64, + "15:base64" => { + "3:varint" => idx, + }, + "23:varint" => 0_i64, + }, + "11:string" => query, + "35:string" => "browse-feed#{ucid}search", + }, + } + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation +end -- cgit v1.2.3 From 68ac18dc9876d8b4328a75b608a8a15e3f322720 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Sun, 3 Apr 2022 23:26:34 +0200 Subject: Remove useless call Follow this comment : https://github.com/iv-org/invidious/pull/2936#discussion_r841277735 --- src/invidious/comments.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 1fd3dcfd..66cbc4fc 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -599,7 +599,7 @@ def content_to_comment_html(content) if length_seconds && length_seconds.as_i > 0 text = %(#{text}) else - text = %(#{reduce_uri("youtube.com/watch?v=#{video_id}")}) + text = %(#{"youtube.com/watch?v=#{video_id}"}) end elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s text = %(#{reduce_uri(url)}) -- cgit v1.2.3 From 62d7abdd9e699779a7e74ed5569aa6d631004210 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 6 Apr 2022 22:23:22 +0200 Subject: Add a user friendly message for when no results are found --- assets/css/search.css | 9 +++++++++ locales/en-US.json | 4 +++- src/invidious/frontend/misc.cr | 14 ++++++++++++++ src/invidious/views/search.ecr | 27 ++++++++++++++++----------- 4 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 src/invidious/frontend/misc.cr (limited to 'src') diff --git a/assets/css/search.css b/assets/css/search.css index 226207a5..a5996362 100644 --- a/assets/css/search.css +++ b/assets/css/search.css @@ -69,6 +69,15 @@ fieldset, legend { #filters-apply { text-align: end; } +/* Error message */ + +.no-results-error { + text-align: center; + line-height: 180%; + font-size: 110%; + padding: 15px 15px 125px 15px; +} + /* Responsive rules */ @media only screen and (max-width: 800px) { diff --git a/locales/en-US.json b/locales/en-US.json index 03df88b6..58098929 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -175,7 +175,9 @@ "Show less": "Show less", "Watch on YouTube": "Watch on YouTube", "Switch Invidious Instance": "Switch Invidious Instance", - "Broken? Try another Invidious Instance": "Broken? Try another Invidious Instance", + "search_message_no_results": "No results found.", + "search_message_change_filters_or_query": "Try widening your search query and/or changing the filters.", + "search_message_use_another_instance": " You can also search on another instance.", "Hide annotations": "Hide annotations", "Show annotations": "Show annotations", "Genre: ": "Genre: ", diff --git a/src/invidious/frontend/misc.cr b/src/invidious/frontend/misc.cr new file mode 100644 index 00000000..43ba9f5c --- /dev/null +++ b/src/invidious/frontend/misc.cr @@ -0,0 +1,14 @@ +module Invidious::Frontend::Misc + extend self + + def redirect_url(env : HTTP::Server::Context) + prefs = env.get("preferences").as(Preferences) + + if prefs.automatic_instance_redirect + current_page = env.get?("current_page").as(String) + redirect_url = "/redirect?referer=#{current_page}" + else + redirect_url = "https://redirect.invidious.io#{env.request.resource}" + end + end +end diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index f1f6ab20..7110703e 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -9,18 +9,13 @@ url_prev_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page - 1}" url_next_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page + 1}" + + redirect_url = Invidious::Frontend::Misc.redirect_url(env) -%> -<% if videos.size == 0 %> -

    - "><%= translate(locale, "Broken? Try another Invidious Instance!") %> -

    -<%- else -%> - <%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %> -<%- end -%> - -<% if videos.size == 0 %>
    <% else %>
    <% end %> +<%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %> +
    @@ -36,11 +31,21 @@
    +<%- if videos.empty? -%> +
    +
    + <%= translate(locale, "search_message_no_results") %>

    + <%= translate(locale, "search_message_change_filters_or_query") %>

    + <%= translate(locale, "search_message_use_another_instance", redirect_url) %> +
    +
    +<%- else -%>
    - <% videos.each do |item| %> + <%- videos.each do |item| -%> <%= rendered "components/item" %> - <% end %> + <%- end -%>
    +<%- end -%>
    -- cgit v1.2.3 From 135aaf56fdd1ad70571e86f21415da44bc138cd8 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 8 Apr 2022 22:52:34 +0200 Subject: Rescue DB errors in get_video() --- src/invidious/videos.cr | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index b50e7b2c..31ae90c7 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1094,6 +1094,10 @@ def get_video(id, refresh = true, region = nil, force_refresh = false) end return video +rescue DB::Error + # Avoid common `DB::PoolRetryAttemptsExceeded` error and friends + # Note: All DB errors inherit from `DB::Error` + return fetch_video(id, region) end def fetch_video(id, region) -- cgit v1.2.3 From 6f705b053aa5dc9287592e0614ef62a41e936d15 Mon Sep 17 00:00:00 2001 From: Leo Date: Sat, 9 Apr 2022 03:20:28 -0300 Subject: Updates the URL of the var url_faq (#3016) --- src/invidious/helpers/errors.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 6155e561..2eab6263 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -49,7 +49,7 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce issue_template += github_details("Backtrace", HTML.escape(exception.inspect_with_backtrace)) # URLs for the error message below - url_faq = "https://github.com/iv-org/documentation/blob/master/FAQ.md" + url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md" url_search_issues = "https://github.com/iv-org/invidious/issues" url_switch = "https://redirect.invidious.io" + env.request.resource -- cgit v1.2.3 From 6c122248f595a338e565bf73b1b6e5a2b761b894 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Thu, 14 Apr 2022 22:42:21 +0200 Subject: Update regex reduce_uri utils Follow this comment : https://github.com/iv-org/invidious/pull/2936#discussion_r850712676 --- src/invidious/helpers/utils.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 8180ab6f..9d403ddc 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -367,7 +367,7 @@ def fetch_random_instance end def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = "…") : String - str = uri.to_s.sub(/https?:\/\//, "") + str = uri.to_s.sub(/^https?:\/\//, "") if str.size > max_length str = "#{str[0, max_length]}#{suffix}" end -- cgit v1.2.3 From 570dbc7b474f33a4410b13a18f6fb2fb624e7545 Mon Sep 17 00:00:00 2001 From: AHOHNMYC <24810600+AHOHNMYC@users.noreply.github.com> Date: Sat, 16 Apr 2022 08:58:45 +0300 Subject: Fix filter checkbox Due to different prefixes in id (`filter-features` in `input` and `filter-feature` in `label`) click on `label` didn't affect corresponding checkbox. --- src/invidious/frontend/search_filters.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/frontend/search_filters.cr b/src/invidious/frontend/search_filters.cr index 68f27b4f..8ac0af2e 100644 --- a/src/invidious/frontend/search_filters.cr +++ b/src/invidious/frontend/search_filters.cr @@ -106,7 +106,7 @@ module Invidious::Frontend::SearchFilters {% feature = value.underscore %} str << "\t\t\t\t\t\t
    " - str << "' -- cgit v1.2.3 From c7c1b8d4f1c5b314f75341f54fd0e9cd6e54c96b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 16 Apr 2022 20:25:25 +0200 Subject: Fix issues in Search::Query --- src/invidious/search/query.cr | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr index 1c2b37d2..34b36b1d 100644 --- a/src/invidious/search/query.cr +++ b/src/invidious/search/query.cr @@ -10,7 +10,7 @@ module Invidious::Search Playlist # "Add playlist item" search end - @type : Type = Type::Regular + getter type : Type = Type::Regular @raw_query : String @query : String = "" @@ -63,14 +63,17 @@ module Invidious::Search # Specific handling case @type - when .playlist?, .channel? - # In "add playlist item" mode, filters are parsed from the query - # string itself (legacy), and the channel is ignored. - # + when .channel? # In "channel search" mode, filters are ignored, but we still parse # the query prevent transmission of legacy filters to youtube. # - @filters, @query, @channel, _ = Filters.from_legacy_filters(@raw_query || "") + _, _, @query, _ = Filters.from_legacy_filters(@raw_query) + # + when .playlist? + # In "add playlist item" mode, filters are parsed from the query + # string itself (legacy), and the channel is ignored. + # + @filters, _, @query, _ = Filters.from_legacy_filters(@raw_query) # when .subscriptions?, .regular? if params["sp"]? @@ -84,7 +87,7 @@ module Invidious::Search if @filters.default? && @raw_query.includes?(':') # Parse legacy filters from query - @filters, @query, @channel, subs = Filters.from_legacy_filters(@raw_query || "") + @filters, @channel, @query, subs = Filters.from_legacy_filters(@raw_query) else @query = @raw_query || "" end -- cgit v1.2.3 From 84b6429ca65ae407e4257fc771f4b760af72d310 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 22 Feb 2022 18:41:43 +0100 Subject: Fix error due to templating engine change --- src/invidious/views/components/item.ecr | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 5f8bde13..ce7af783 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -52,11 +52,11 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - <% if plid = env.get?("remove_playlist_items") %> -
    " method="post"> + <% if plid_form = env.get?("remove_playlist_items") %> + " method="post"> ">

    - + @@ -117,11 +117,11 @@

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

    - + -- cgit v1.2.3 From 1f66d7ef7471acb07642bb3b8132d824877176fb Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 10 Apr 2022 22:53:03 +0200 Subject: Keep using kilt for rendering Directly using Crystal's ECR seems to be causing issues, so don't use kemal's 'render' macro and patch 'content_for' to have the same behavior as before Kemal v1.1.1 --- shard.lock | 4 ++++ shard.yml | 3 +++ src/ext/kemal_content_for.cr | 16 ++++++++++++++++ src/invidious.cr | 5 +++++ src/invidious/helpers/macros.cr | 12 +++++++++--- 5 files changed, 37 insertions(+), 3 deletions(-) create mode 100644 src/ext/kemal_content_for.cr (limited to 'src') diff --git a/shard.lock b/shard.lock index 6cc20230..cdce1160 100644 --- a/shard.lock +++ b/shard.lock @@ -20,6 +20,10 @@ shards: git: https://github.com/kemalcr/kemal.git version: 1.1.2 + kilt: + git: https://github.com/jeromegn/kilt.git + version: 0.6.1 + lsquic: git: https://github.com/iv-org/lsquic.cr.git version: 2.18.1-2 diff --git a/shard.yml b/shard.yml index 76e67846..9c9b0d37 100644 --- a/shard.yml +++ b/shard.yml @@ -19,6 +19,9 @@ dependencies: kemal: github: kemalcr/kemal version: ~> 1.1.2 + kilt: + github: jeromegn/kilt + version: ~> 0.6.1 protodec: github: iv-org/protodec version: ~> 0.1.4 diff --git a/src/ext/kemal_content_for.cr b/src/ext/kemal_content_for.cr new file mode 100644 index 00000000..a4f3fd96 --- /dev/null +++ b/src/ext/kemal_content_for.cr @@ -0,0 +1,16 @@ +# Overrides for Kemal's `content_for` macro in order to keep using +# kilt as it was before Kemal v1.1.1 (Kemal PR #618). + +require "kemal" +require "kilt" + +macro content_for(key, file = __FILE__) + %proc = ->() { + __kilt_io__ = IO::Memory.new + {{ yield }} + __kilt_io__.to_s + } + + CONTENT_FOR_BLOCKS[{{key}}] = Tuple.new {{file}}, %proc + nil +end diff --git a/src/invidious.cr b/src/invidious.cr index 9f3d5d10..631a6e78 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -16,7 +16,12 @@ require "digest/md5" require "file_utils" + +# Require kemal, kilt, then our own overrides require "kemal" +require "kilt" +require "./ext/kemal_content_for.cr" + require "athena-negotiation" require "openssl/hmac" require "option_parser" diff --git a/src/invidious/helpers/macros.cr b/src/invidious/helpers/macros.cr index 75df1612..43e7171b 100644 --- a/src/invidious/helpers/macros.cr +++ b/src/invidious/helpers/macros.cr @@ -48,13 +48,19 @@ module JSON::Serializable end end -macro templated(filename, template = "template", navbar_search = true) +macro templated(_filename, template = "template", navbar_search = true) navbar_search = {{navbar_search}} - render "src/invidious/views/#{{{filename}}}.ecr", "src/invidious/views/#{{{template}}}.ecr" + + {{ filename = "src/invidious/views/" + _filename + ".ecr" }} + {{ layout = "src/invidious/views/" + template + ".ecr" }} + + __content_filename__ = {{filename}} + content = Kilt.render({{filename}}) + Kilt.render({{layout}}) end macro rendered(filename) - render "src/invidious/views/#{{{filename}}}.ecr" + Kilt.render("src/invidious/views/#{{{filename}}}.ecr") end # Similar to Kemals halt method but works in a -- cgit v1.2.3 From 0a1614a872c10787235b389542bbbf5eabb4da54 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 10 Apr 2022 23:07:06 +0200 Subject: Also move the other Kemal class override to src/ext/ --- src/ext/kemal_static_file_handler.cr | 193 +++++++++++++++++++++++++++ src/invidious.cr | 1 + src/invidious/helpers/static_file_handler.cr | 193 --------------------------- 3 files changed, 194 insertions(+), 193 deletions(-) create mode 100644 src/ext/kemal_static_file_handler.cr delete mode 100644 src/invidious/helpers/static_file_handler.cr (limited to 'src') diff --git a/src/ext/kemal_static_file_handler.cr b/src/ext/kemal_static_file_handler.cr new file mode 100644 index 00000000..6ef2d74c --- /dev/null +++ b/src/ext/kemal_static_file_handler.cr @@ -0,0 +1,193 @@ +# Since systems have a limit on number of open files (`ulimit -a`), +# we serve them from memory to avoid 'Too many open files' without needing +# to modify ulimit. +# +# Very heavily re-used: +# https://github.com/kemalcr/kemal/blob/master/src/kemal/helpers/helpers.cr +# https://github.com/kemalcr/kemal/blob/master/src/kemal/static_file_handler.cr +# +# Changes: +# - A `send_file` overload is added which supports sending a Slice, file_path, filestat +# - `StaticFileHandler` is patched to cache to and serve from @cached_files + +private def multipart(file, env : HTTP::Server::Context) + # See http://httpwg.org/specs/rfc7233.html + fileb = file.size + startb = endb = 0 + + if match = env.request.headers["Range"].match /bytes=(\d{1,})-(\d{0,})/ + startb = match[1].to_i { 0 } if match.size >= 2 + endb = match[2].to_i { 0 } if match.size >= 3 + end + + endb = fileb - 1 if endb == 0 + + if startb < endb < fileb + content_length = 1 + endb - startb + env.response.status_code = 206 + env.response.content_length = content_length + env.response.headers["Accept-Ranges"] = "bytes" + env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST + + if startb > 1024 + skipped = 0 + # file.skip only accepts values less or equal to 1024 (buffer size, undocumented) + until (increase_skipped = skipped + 1024) > startb + file.skip(1024) + skipped = increase_skipped + end + if (skipped_minus_startb = skipped - startb) > 0 + file.skip skipped_minus_startb + end + else + file.skip(startb) + end + + IO.copy(file, env.response, content_length) + else + env.response.content_length = fileb + env.response.status_code = 200 # Range not satisfable, see 4.4 Note + IO.copy(file, env.response) + end +end + +# Set the Content-Disposition to "attachment" with the specified filename, +# instructing the user agents to prompt to save. +private def attachment(env : HTTP::Server::Context, filename : String? = nil, disposition : String? = nil) + disposition = "attachment" if disposition.nil? && filename + if disposition && filename + env.response.headers["Content-Disposition"] = "#{disposition}; filename=\"#{File.basename(filename)}\"" + end +end + +def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt8), filestat : File::Info, filename : String? = nil, disposition : String? = nil) + config = Kemal.config.serve_static + mime_type = MIME.from_filename(file_path, "application/octet-stream") + env.response.content_type = mime_type + env.response.headers["Accept-Ranges"] = "bytes" + env.response.headers["X-Content-Type-Options"] = "nosniff" + minsize = 860 # http://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits ?? + request_headers = env.request.headers + filesize = data.bytesize + attachment(env, filename, disposition) + + Kemal.config.static_headers.try(&.call(env.response, file_path, filestat)) + + file = IO::Memory.new(data) + if env.request.method == "GET" && env.request.headers.has_key?("Range") + return multipart(file, env) + end + + condition = config.is_a?(Hash) && config["gzip"]? == true && filesize > minsize && Kemal::Utils.zip_types(file_path) + if condition && request_headers.includes_word?("Accept-Encoding", "gzip") + env.response.headers["Content-Encoding"] = "gzip" + Compress::Gzip::Writer.open(env.response) do |deflate| + IO.copy(file, deflate) + end + elsif condition && request_headers.includes_word?("Accept-Encoding", "deflate") + env.response.headers["Content-Encoding"] = "deflate" + Compress::Deflate::Writer.open(env.response) do |deflate| + IO.copy(file, deflate) + end + else + env.response.content_length = filesize + IO.copy(file, env.response) + end + + return +end + +module Kemal + class StaticFileHandler < HTTP::StaticFileHandler + CACHE_LIMIT = 5_000_000 # 5MB + @cached_files = {} of String => {data: Bytes, filestat: File::Info} + + def call(context : HTTP::Server::Context) + return call_next(context) if context.request.path.not_nil! == "/" + + case context.request.method + when "GET", "HEAD" + else + if @fallthrough + call_next(context) + else + context.response.status_code = 405 + context.response.headers.add("Allow", "GET, HEAD") + end + return + end + + config = Kemal.config.serve_static + original_path = context.request.path.not_nil! + request_path = URI.decode_www_form(original_path) + + # File path cannot contains '\0' (NUL) because all filesystem I know + # don't accept '\0' character as file name. + if request_path.includes? '\0' + context.response.status_code = 400 + return + end + + expanded_path = File.expand_path(request_path, "/") + is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/' + expanded_path = expanded_path + '/' + true + else + expanded_path.ends_with? '/' + end + + file_path = File.join(@public_dir, expanded_path) + + if file = @cached_files[file_path]? + last_modified = file[:filestat].modification_time + add_cache_headers(context.response.headers, last_modified) + + if cache_request?(context, last_modified) + context.response.status_code = 304 + return + end + + send_file(context, file_path, file[:data], file[:filestat]) + else + is_dir = Dir.exists? file_path + + if request_path != expanded_path + redirect_to context, expanded_path + elsif is_dir && !is_dir_path + redirect_to context, expanded_path + '/' + end + + if Dir.exists?(file_path) + if config.is_a?(Hash) && config["dir_listing"] == true + context.response.content_type = "text/html" + directory_listing(context.response, request_path, file_path) + else + call_next(context) + end + elsif File.exists?(file_path) + last_modified = modification_time(file_path) + add_cache_headers(context.response.headers, last_modified) + + if cache_request?(context, last_modified) + context.response.status_code = 304 + return + end + + if @cached_files.sum(&.[1][:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT + data = Bytes.new(size) + File.open(file_path, &.read(data)) + + filestat = File.info(file_path) + + @cached_files[file_path] = {data: data, filestat: filestat} + send_file(context, file_path, data, filestat) + else + send_file(context, file_path) + end + else + call_next(context) + end + end + end + end +end diff --git a/src/invidious.cr b/src/invidious.cr index 631a6e78..dd240852 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -21,6 +21,7 @@ require "file_utils" require "kemal" require "kilt" require "./ext/kemal_content_for.cr" +require "./ext/kemal_static_file_handler.cr" require "athena-negotiation" require "openssl/hmac" diff --git a/src/invidious/helpers/static_file_handler.cr b/src/invidious/helpers/static_file_handler.cr deleted file mode 100644 index 6ef2d74c..00000000 --- a/src/invidious/helpers/static_file_handler.cr +++ /dev/null @@ -1,193 +0,0 @@ -# Since systems have a limit on number of open files (`ulimit -a`), -# we serve them from memory to avoid 'Too many open files' without needing -# to modify ulimit. -# -# Very heavily re-used: -# https://github.com/kemalcr/kemal/blob/master/src/kemal/helpers/helpers.cr -# https://github.com/kemalcr/kemal/blob/master/src/kemal/static_file_handler.cr -# -# Changes: -# - A `send_file` overload is added which supports sending a Slice, file_path, filestat -# - `StaticFileHandler` is patched to cache to and serve from @cached_files - -private def multipart(file, env : HTTP::Server::Context) - # See http://httpwg.org/specs/rfc7233.html - fileb = file.size - startb = endb = 0 - - if match = env.request.headers["Range"].match /bytes=(\d{1,})-(\d{0,})/ - startb = match[1].to_i { 0 } if match.size >= 2 - endb = match[2].to_i { 0 } if match.size >= 3 - end - - endb = fileb - 1 if endb == 0 - - if startb < endb < fileb - content_length = 1 + endb - startb - env.response.status_code = 206 - env.response.content_length = content_length - env.response.headers["Accept-Ranges"] = "bytes" - env.response.headers["Content-Range"] = "bytes #{startb}-#{endb}/#{fileb}" # MUST - - if startb > 1024 - skipped = 0 - # file.skip only accepts values less or equal to 1024 (buffer size, undocumented) - until (increase_skipped = skipped + 1024) > startb - file.skip(1024) - skipped = increase_skipped - end - if (skipped_minus_startb = skipped - startb) > 0 - file.skip skipped_minus_startb - end - else - file.skip(startb) - end - - IO.copy(file, env.response, content_length) - else - env.response.content_length = fileb - env.response.status_code = 200 # Range not satisfable, see 4.4 Note - IO.copy(file, env.response) - end -end - -# Set the Content-Disposition to "attachment" with the specified filename, -# instructing the user agents to prompt to save. -private def attachment(env : HTTP::Server::Context, filename : String? = nil, disposition : String? = nil) - disposition = "attachment" if disposition.nil? && filename - if disposition && filename - env.response.headers["Content-Disposition"] = "#{disposition}; filename=\"#{File.basename(filename)}\"" - end -end - -def send_file(env : HTTP::Server::Context, file_path : String, data : Slice(UInt8), filestat : File::Info, filename : String? = nil, disposition : String? = nil) - config = Kemal.config.serve_static - mime_type = MIME.from_filename(file_path, "application/octet-stream") - env.response.content_type = mime_type - env.response.headers["Accept-Ranges"] = "bytes" - env.response.headers["X-Content-Type-Options"] = "nosniff" - minsize = 860 # http://webmasters.stackexchange.com/questions/31750/what-is-recommended-minimum-object-size-for-gzip-performance-benefits ?? - request_headers = env.request.headers - filesize = data.bytesize - attachment(env, filename, disposition) - - Kemal.config.static_headers.try(&.call(env.response, file_path, filestat)) - - file = IO::Memory.new(data) - if env.request.method == "GET" && env.request.headers.has_key?("Range") - return multipart(file, env) - end - - condition = config.is_a?(Hash) && config["gzip"]? == true && filesize > minsize && Kemal::Utils.zip_types(file_path) - if condition && request_headers.includes_word?("Accept-Encoding", "gzip") - env.response.headers["Content-Encoding"] = "gzip" - Compress::Gzip::Writer.open(env.response) do |deflate| - IO.copy(file, deflate) - end - elsif condition && request_headers.includes_word?("Accept-Encoding", "deflate") - env.response.headers["Content-Encoding"] = "deflate" - Compress::Deflate::Writer.open(env.response) do |deflate| - IO.copy(file, deflate) - end - else - env.response.content_length = filesize - IO.copy(file, env.response) - end - - return -end - -module Kemal - class StaticFileHandler < HTTP::StaticFileHandler - CACHE_LIMIT = 5_000_000 # 5MB - @cached_files = {} of String => {data: Bytes, filestat: File::Info} - - def call(context : HTTP::Server::Context) - return call_next(context) if context.request.path.not_nil! == "/" - - case context.request.method - when "GET", "HEAD" - else - if @fallthrough - call_next(context) - else - context.response.status_code = 405 - context.response.headers.add("Allow", "GET, HEAD") - end - return - end - - config = Kemal.config.serve_static - original_path = context.request.path.not_nil! - request_path = URI.decode_www_form(original_path) - - # File path cannot contains '\0' (NUL) because all filesystem I know - # don't accept '\0' character as file name. - if request_path.includes? '\0' - context.response.status_code = 400 - return - end - - expanded_path = File.expand_path(request_path, "/") - is_dir_path = if original_path.ends_with?('/') && !expanded_path.ends_with? '/' - expanded_path = expanded_path + '/' - true - else - expanded_path.ends_with? '/' - end - - file_path = File.join(@public_dir, expanded_path) - - if file = @cached_files[file_path]? - last_modified = file[:filestat].modification_time - add_cache_headers(context.response.headers, last_modified) - - if cache_request?(context, last_modified) - context.response.status_code = 304 - return - end - - send_file(context, file_path, file[:data], file[:filestat]) - else - is_dir = Dir.exists? file_path - - if request_path != expanded_path - redirect_to context, expanded_path - elsif is_dir && !is_dir_path - redirect_to context, expanded_path + '/' - end - - if Dir.exists?(file_path) - if config.is_a?(Hash) && config["dir_listing"] == true - context.response.content_type = "text/html" - directory_listing(context.response, request_path, file_path) - else - call_next(context) - end - elsif File.exists?(file_path) - last_modified = modification_time(file_path) - add_cache_headers(context.response.headers, last_modified) - - if cache_request?(context, last_modified) - context.response.status_code = 304 - return - end - - if @cached_files.sum(&.[1][:data].bytesize) + (size = File.size(file_path)) < CACHE_LIMIT - data = Bytes.new(size) - File.open(file_path, &.read(data)) - - filestat = File.info(file_path) - - @cached_files[file_path] = {data: data, filestat: filestat} - send_file(context, file_path, data, filestat) - else - send_file(context, file_path) - end - else - call_next(context) - end - end - end - end -end -- cgit v1.2.3 From 3702e8c6fe5d36011ca596f7cbc174ceb62c9ee3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 17 Apr 2022 18:02:47 +0200 Subject: Fix comment "pings" (#3038) --- src/invidious/comments.cr | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 66cbc4fc..002983ee 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -602,7 +602,13 @@ def content_to_comment_html(content) text = %(#{"youtube.com/watch?v=#{video_id}"}) end elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s - text = %(#{reduce_uri(url)}) + if text.starts_with?(/\s?@/) + # Handle "pings" in comments differently + # See: https://github.com/iv-org/invidious/issues/3038 + text = %(#{text}) + else + text = %(#{reduce_uri(url)}) + end end end -- cgit v1.2.3 From da53de209758a037d47fec97c266ed0fb8f7eabe Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Wed, 20 Apr 2022 00:42:09 +0200 Subject: Fix regression related of timestamp 0:00 --- src/invidious/comments.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 002983ee..c6e7fd17 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -596,7 +596,7 @@ def content_to_comment_html(content) length_seconds = watch_endpoint["startTimeSeconds"]? video_id = watch_endpoint["videoId"].as_s - if length_seconds && length_seconds.as_i > 0 + if length_seconds && length_seconds.as_i >= 0 text = %(#{text}) else text = %(#{"youtube.com/watch?v=#{video_id}"}) -- cgit v1.2.3 From 2ea986326d1a64c294025b79088032f3c77e8320 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Fri, 22 Apr 2022 22:37:45 +0200 Subject: Bump videojs to 7.12.1 (#3011) --- assets/js/player.js | 57 ++++++++++++++-------- .../js/silvermine-videojs-quality-selector.min.js | 4 +- src/invidious/routes/api/manifest.cr | 4 +- src/invidious/views/components/player.ecr | 18 ++++++- src/invidious/views/embed.ecr | 3 +- src/invidious/views/watch.ecr | 3 +- videojs-dependencies.yml | 8 ++- 7 files changed, 63 insertions(+), 34 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index f4440de1..f5bec651 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -49,6 +49,42 @@ videojs.Vhs.xhr.beforeRequest = function(options) { var player = videojs('player', options); +player.on('error', () => { + if (video_data.params.quality !== 'dash') { + if (!player.currentSrc().includes("local=true") && !video_data.local_disabled) { + var currentSources = player.currentSources(); + for (var i = 0; i < currentSources.length; i++) { + currentSources[i]["src"] += "&local=true" + } + player.src(currentSources) + } + else if (player.error().code === 2 || player.error().code === 4) { + setTimeout(function (event) { + console.log('An error occurred in the player, reloading...'); + + var currentTime = player.currentTime(); + var playbackRate = player.playbackRate(); + var paused = player.paused(); + + player.load(); + + if (currentTime > 0.5) currentTime -= 0.5; + + player.currentTime(currentTime); + player.playbackRate(playbackRate); + + if (!paused) player.play(); + }, 10000); + } + } +}); + +if (video_data.params.quality == 'dash') { + player.reloadSourceOnError({ + errorInterval: 10 + }); +} + /** * Function for add time argument to url * @param {String} url @@ -144,27 +180,6 @@ if (isMobile()) { }) } -player.on('error', function (event) { - if (player.error().code === 2 || player.error().code === 4) { - setTimeout(function (event) { - console.log('An error occurred in the player, reloading...'); - - var currentTime = player.currentTime(); - var playbackRate = player.playbackRate(); - var paused = player.paused(); - - player.load(); - - if (currentTime > 0.5) currentTime -= 0.5; - - player.currentTime(currentTime); - player.playbackRate(playbackRate); - - if (!paused) player.play(); - }, 5000); - } -}); - // Enable VR video support if (!video_data.params.listen && video_data.vr && video_data.params.vr_mode) { player.crossOrigin("anonymous") diff --git a/assets/js/silvermine-videojs-quality-selector.min.js b/assets/js/silvermine-videojs-quality-selector.min.js index 88621e8d..1877047d 100644 --- a/assets/js/silvermine-videojs-quality-selector.min.js +++ b/assets/js/silvermine-videojs-quality-selector.min.js @@ -1,4 +1,4 @@ -/*! @silvermine/videojs-quality-selector 2020-03-02 v1.1.2-36-g64d620a-dirty */ +/*! @silvermine/videojs-quality-selector 2022-04-13 v1.1.2-43-gaa06e72-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":">",'"':""","'":"'","`":"`"},W=h.invert(P);h.escape=D(P),h.unescape=D(W),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 Y(n){return"\\"+K[n]}var z=/(.)^/,K={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},G=/\\|'|\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||z).source,(n.interpolate||z).source,(n.evaluate||z).source].join("|")+"|$","g"),o=0,c="__p+='";u.replace(r,function(n,e,t,r,i){return c+=u.slice(o,i).replace(G,Y),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 H(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),H(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],H(this,n)}}),h.each(["concat","join","slice"],function(n){var e=r[n];h.prototype[n]=function(){return H(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.PLAYER_SOURCES_CHANGED,function(){this.update()}.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",PLAYER_SOURCES_CHANGED:"playerSourcesChanged"}},{}],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(),u.isEqual(r,i._qualitySelectorPreviousSources)||(i.trigger(o.PLAYER_SOURCES_CHANGED,r),i._qualitySelectorPreviousSources=r),t=u.find(r,function(n){return!0===n.selected||"true"===n.selected||"selected"===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]); +!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":">",'"':""","'":"'","`":"`"},W=h.invert(P);h.escape=D(P),h.unescape=D(W),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 Y(n){return"\\"+K[n]}var z=/(.)^/,K={"'":"'","\\":"\\","\r":"r","\n":"n","\u2028":"u2028","\u2029":"u2029"},G=/\\|'|\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||z).source,(n.interpolate||z).source,(n.evaluate||z).source].join("|")+"|$","g"),o=0,c="__p+='";u.replace(r,function(n,e,t,r,i){return c+=u.slice(o,i).replace(G,Y),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 H(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),H(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],H(this,n)}}),h.each(["concat","join","slice"],function(n){var e=r[n];h.prototype[n]=function(){return H(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.PLAYER_SOURCES_CHANGED,function(){this.update()}.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 n=n.filter(function(n){return null==n.hidequalityoption}),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",PLAYER_SOURCES_CHANGED:"playerSourcesChanged"}},{}],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(),u.isEqual(r,i._qualitySelectorPreviousSources)||(i.trigger(o.PLAYER_SOURCES_CHANGED,r),i._qualitySelectorPreviousSources=r),t=u.find(r,function(n){return!0===n.selected||"true"===n.selected||"selected"===n.selected})||n,i.trigger(o.QUALITY_SELECTED,t),e(null,t)}}})}},{"../events":5,underscore:2}],8:[function(n,e,t){"use strict";n("./index")()},{"./index":6}],9:[function(n,e,t){"use strict";var r=n("class.extend");e.exports=r.extend({init:function(n,e){this._player=n,this._seekToTime=e,this._hasFinished=!1,this._keepThisInstanceWhenPlayerSourcesChange=!1,this._seekWhenSafe()},_seekWhenSafe:function(){this._player.readyState()<3?(this._seekFn=this._seek.bind(this),this._player.one("canplay",this._seekFn)):this._seek()},onPlayerSourcesChange:function(){this._keepThisInstanceWhenPlayerSourcesChange?this._keepThisInstanceWhenPlayerSourcesChange=!1:this.cancel()},onQualitySelectionChange:function(){this.hasFinished()||(this._keepThisInstanceWhenPlayerSourcesChange=!0)},_seek:function(){this._player.currentTime(this._seekToTime),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0},hasFinished:function(){return this._hasFinished},cancel:function(){this._player.off("canplay",this._seekFn),this._keepThisInstanceWhenPlayerSourcesChange=!1,this._hasFinished=!0}})},{"class.extend":1}]},{},[8]); //# sourceMappingURL=silvermine-videojs-quality-selector.min.js.map \ No newline at end of file diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index ca429df5..23d11f65 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -56,7 +56,7 @@ module Invidious::Routes::API::Manifest xml.element("Period") do i = 0 - {"audio/mp4", "audio/webm"}.each do |mime_type| + {"audio/mp4"}.each do |mime_type| mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } next if mime_streams.empty? @@ -83,7 +83,7 @@ module Invidious::Routes::API::Manifest potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} - {"video/mp4", "video/webm"}.each do |mime_type| + {"video/mp4"}.each do |mime_type| mime_streams = video_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } next if mime_streams.empty? diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index 206ba380..fffefc9a 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -7,8 +7,19 @@ <% else %> <% if params.listen %> - <% audio_streams.each_with_index do |fmt, i| %> - <% if params.local %>&local=true<% end %>" type='<%= fmt["mimeType"] %>' label="<%= fmt["bitrate"] %>k" selected="<%= i == 0 ? true : false %>"> + <% audio_streams.each_with_index do |fmt, i| + src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" + src_url += "&local=true" if params.local + + bitrate = fmt["bitrate"] + mimetype = HTML.escape(fmt["mimeType"].as_s) + + selected = i == 0 ? true : false + %> + + <% if !params.local && !CONFIG.disabled?("local") %> + + <% end %> <% end %> <% else %> <% if params.quality == "dash" %> @@ -28,6 +39,9 @@ selected = params.quality ? (params.quality == quality) : (i == 0) %> + <% if !params.local && !CONFIG.disabled?("local") %> + + <% end %> <% end %> <% end %> diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index 27a8e266..ce5ff7f0 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -24,7 +24,8 @@ "video_series" => video_series, "params" => params, "preferences" => preferences, - "premiere_timestamp" => video.premiere_timestamp.try &.to_unix + "premiere_timestamp" => video.premiere_timestamp.try &.to_unix, + "local_disabled" => CONFIG.disabled?("local") }.to_pretty_json %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 0e4af3ab..2e493f4c 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -64,7 +64,8 @@ we're going to need to do it here in order to allow for translations. "preferences" => preferences, "premiere_timestamp" => video.premiere_timestamp.try &.to_unix, "vr" => video.is_vr, - "projection_type" => video.projection_type + "projection_type" => video.projection_type, + "local_disabled" => CONFIG.disabled?("local") }.to_pretty_json %> diff --git a/videojs-dependencies.yml b/videojs-dependencies.yml index 6de23d25..e9ccc9dd 100644 --- a/videojs-dependencies.yml +++ b/videojs-dependencies.yml @@ -1,9 +1,7 @@ -# Due to an firefox issue, we're stuck on 7.11.0. If you're hosting a private instance -# and you're using a chromium based browser, feel free to bump this to the latest version -# in order to get support for higher resolutions on more videos. +# Due to a 'video append of' error (see #3011), we're stuck on 7.12.1. video.js: - version: 7.11.0 - shasum: e20747d890716085e7255a90d73c00f32324a224 + version: 7.12.1 + shasum: 1d12eeb1f52e3679e8e4c987d9b9eb37e2247fa2 videojs-contrib-quality-levels: version: 2.1.0 -- cgit v1.2.3 From 0503d2a9f307538f595be53ae1e9e8713f1e95ac Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 27 Apr 2022 00:20:48 +0200 Subject: Fix 'adaptiveFormats' not available for livestreams in videos API --- src/invidious/routes/api/manifest.cr | 6 ++++++ src/invidious/videos.cr | 24 +++++++++++++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index 23d11f65..d77389a8 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -62,6 +62,9 @@ module Invidious::Routes::API::Manifest xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do mime_streams.each do |fmt| + # OFT streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) + next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) + codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') bandwidth = fmt["bitrate"].as_i itag = fmt["itag"].as_i @@ -90,6 +93,9 @@ module Invidious::Routes::API::Manifest heights = [] of Int32 xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do mime_streams.each do |fmt| + # OFT streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) + next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) + codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') bandwidth = fmt["bitrate"].as_i itag = fmt["itag"].as_i diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 31ae90c7..7e37cf12 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -374,18 +374,25 @@ struct Video json.array do self.adaptive_fmts.each do |fmt| json.object do - json.field "index", "#{fmt["indexRange"]["start"]}-#{fmt["indexRange"]["end"]}" - json.field "bitrate", fmt["bitrate"].as_i.to_s - json.field "init", "#{fmt["initRange"]["start"]}-#{fmt["initRange"]["end"]}" + # Only available on regular videos, not livestreams/OFT streams + if init_range = fmt["initRange"]? + json.field "init", "#{init_range["start"]}-#{init_range["end"]}" + end + if index_range = fmt["indexRange"]? + json.field "index", "#{index_range["start"]}-#{index_range["end"]}" + end + + # Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only) + json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? + json.field "url", fmt["url"] json.field "itag", fmt["itag"].as_i.to_s json.field "type", fmt["mimeType"] - json.field "clen", fmt["contentLength"] + json.field "clen", fmt["contentLength"]? || "-1" json.field "lmt", fmt["lastModified"] json.field "projectionType", fmt["projectionType"] - fmt_info = itag_to_metadata?(fmt["itag"]) - if fmt_info + if fmt_info = itag_to_metadata?(fmt["itag"]) fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 json.field "fps", fps json.field "container", fmt_info["ext"] @@ -612,6 +619,7 @@ struct Video fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? end + fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } @fmt_stream = fmt_stream return @fmt_stream.as(Array(Hash(String, JSON::Any))) @@ -631,9 +639,7 @@ struct Video fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? end - # See https://github.com/TeamNewPipe/NewPipe/issues/2415 - # Some streams are segmented by URL `sq/` rather than index, for now we just filter them out - fmt_stream.reject! { |f| !f["indexRange"]? } + fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } @adaptive_fmts = fmt_stream return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) -- cgit v1.2.3 From 8144308aee078d2322491e9848247df7257d756b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 27 Apr 2022 00:21:23 +0200 Subject: Add extra data to 'adaptiveFormats' in videos API --- src/invidious/videos.cr | 9 +++++++++ 1 file changed, 9 insertions(+) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 7e37cf12..cb860032 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -412,6 +412,15 @@ struct Video end end end + + # Audio-related data + json.field "audioQuality", fmt["audioQuality"] if fmt.has_key?("audioQuality") + json.field "audioSampleRate", fmt["audioSampleRate"].as_s.to_i if fmt.has_key?("audioSampleRate") + json.field "audioChannels", fmt["audioChannels"] if fmt.has_key?("audioChannels") + + # Extra misc stuff + json.field "colorInfo", fmt["colorInfo"] if fmt.has_key?("colorInfo") + json.field "captionTrack", fmt["captionTrack"] if fmt.has_key?("captionTrack") end end end -- cgit v1.2.3 From b7f0b054b85e60ae7c91144cb44d8139e468b23a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 27 Apr 2022 21:44:31 +0200 Subject: It's OTF, not OFT --- src/invidious/routes/api/manifest.cr | 4 ++-- src/invidious/videos.cr | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index d77389a8..8bc36946 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -62,7 +62,7 @@ module Invidious::Routes::API::Manifest xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do mime_streams.each do |fmt| - # OFT streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) + # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') @@ -93,7 +93,7 @@ module Invidious::Routes::API::Manifest heights = [] of Int32 xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, scanType: "progressive") do mime_streams.each do |fmt| - # OFT streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) + # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index cb860032..27c2b6d1 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -374,7 +374,7 @@ struct Video json.array do self.adaptive_fmts.each do |fmt| json.object do - # Only available on regular videos, not livestreams/OFT streams + # Only available on regular videos, not livestreams/OTF streams if init_range = fmt["initRange"]? json.field "init", "#{init_range["start"]}-#{init_range["end"]}" end -- cgit v1.2.3 From 595c3fb833c5744fc83f0936a11cb8c16393190e Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Sat, 30 Apr 2022 23:42:38 +0200 Subject: Revert "Youtube verification badge" (#3070) --- src/invidious/channels/about.cr | 7 +---- src/invidious/comments.cr | 8 +---- src/invidious/helpers/serialized_yt_data.cr | 7 +---- src/invidious/routes/feeds.cr | 1 - src/invidious/videos.cr | 14 --------- src/invidious/views/channel.ecr | 2 +- src/invidious/views/community.ecr | 2 +- src/invidious/views/components/item.ecr | 6 ++-- src/invidious/views/playlists.ecr | 2 +- src/invidious/views/watch.ecr | 6 ++-- src/invidious/yt_backend/extractors.cr | 48 +++++++++-------------------- 11 files changed, 26 insertions(+), 77 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index d48fd1fb..4f82a0f1 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -12,8 +12,7 @@ record AboutChannel, joined : Time, is_family_friendly : Bool, allowed_regions : Array(String), - tabs : Array(String), - verified : Bool + tabs : Array(String) record AboutRelatedChannel, ucid : String, @@ -71,9 +70,6 @@ def get_about_info(ucid, locale) : AboutChannel # if banner.includes? "channels/c4/default_banner" # banner = nil # end - # author_verified_badges = initdata["header"]?.try &.["c4TabbedHeaderRenderer"]?.try &.["badges"]? - author_verified_badge = initdata["header"].dig?("c4TabbedHeaderRenderer", "badges", 0, "metadataBadgeRenderer", "tooltip") - author_verified = (author_verified_badge && author_verified_badge == "Verified") description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" description_html = HTML.escape(description) @@ -132,7 +128,6 @@ def get_about_info(ucid, locale) : AboutChannel is_family_friendly: is_family_friendly, allowed_regions: allowed_regions, tabs: tabs, - verified: author_verified || false, ) end diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 3ae49aa6..c6e7fd17 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -146,8 +146,6 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b content_html = node_comment["contentText"]?.try { |t| parse_content(t) } || "" author = node_comment["authorText"]?.try &.["simpleText"]? || "" - json.field "verified", (node_comment["authorCommentBadge"]? != nil) - json.field "author", author json.field "authorThumbnails" do json.array do @@ -331,11 +329,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end author_name = HTML.escape(child["author"].as_s) - if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool - author_name += " " - elsif child["verified"]?.try &.as_bool - author_name += " " - end + html << <<-END_HTML

    diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 3918bd13..bfbc237c 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -12,7 +12,6 @@ struct SearchVideo property live_now : Bool property premium : Bool property premiere_timestamp : Time? - property author_verified : Bool def to_xml(auto_generated, query_params, xml : XML::Builder) query_params["v"] = self.id @@ -130,7 +129,6 @@ struct SearchPlaylist property video_count : Int32 property videos : Array(SearchPlaylistVideo) property thumbnail : String? - property author_verified : Bool def to_json(locale : String?, json : JSON::Builder) json.object do @@ -143,8 +141,6 @@ struct SearchPlaylist json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" - json.field "authorVerified", self.author_verified - json.field "videoCount", self.video_count json.field "videos" do json.array do @@ -186,7 +182,6 @@ struct SearchChannel property video_count : Int32 property description_html : String property auto_generated : Bool - property author_verified : Bool def to_json(locale : String?, json : JSON::Builder) json.object do @@ -194,7 +189,7 @@ struct SearchChannel json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" - json.field "authorVerified", self.author_verified + json.field "authorThumbnails" do json.array do qualities = {32, 48, 76, 100, 176, 512} diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index b5b58399..f7f7b426 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -182,7 +182,6 @@ module Invidious::Routes::Feeds paid: false, premium: false, premiere_timestamp: nil, - author_verified: false, # ¯\_(ツ)_/¯ }) end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 4cb049ca..27c2b6d1 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -609,10 +609,6 @@ struct Video info["authorThumbnail"]?.try &.as_s || "" end - def author_verified : Bool - info["authorVerified"].try &.as_bool || false - end - def sub_count_text : String info["subCountText"]?.try &.as_s || "-" end @@ -864,12 +860,6 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? .try &.dig?("runs", 0) author = channel_info.try &.dig?("text") - author_verified_badge = related["ownerBadges"]?.try do |badges_array| - badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") - end - - author_verified = (author_verified_badge && author_verified_badge.size > 0).to_s - ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } # "4,088,033 views", only available on compact renderer @@ -893,7 +883,6 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? "length_seconds" => JSON::Any.new(length || "0"), "view_count" => JSON::Any.new(view_count || "0"), "short_view_count" => JSON::Any.new(short_view_count || "0"), - "author_verified" => JSON::Any.new(author_verified), } end @@ -1088,9 +1077,6 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url") - author_verified_badge = author_info.try &.dig?("badges", 0, "metadataBadgeRenderer", "tooltip") - params["authorVerified"] = JSON::Any.new((author_verified_badge && author_verified_badge == "Verified")) - params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 92f81ee4..40b553a9 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -20,7 +20,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %>
    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 3bc29e55..f0add06b 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -19,7 +19,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %>
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index fb7ad1dc..ce7af783 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -8,7 +8,7 @@ "/> <% end %> -

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    +

    <%= HTML.escape(item.author) %>

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -30,7 +30,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

    +

    <%= HTML.escape(item.author) %>

    <% when MixVideo %> @@ -142,7 +142,7 @@
    <% endpoint_params = "?v=#{item.id}" %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index c8718e7b..12dba088 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -19,7 +19,7 @@
    - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> + <%= author %>
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 8b6eb903..2e493f4c 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -207,7 +207,7 @@ we're going to need to do it here in order to allow for translations. <% if !video.author_thumbnail.empty? %> <% end %> - <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %> + <%= author %>
    @@ -281,9 +281,9 @@ we're going to need to do it here in order to allow for translations.
    <% if rv["ucid"]? %> - "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> + "><%= rv["author"]? %> <% else %> - <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> + <%= rv["author"]? %> <% end %>
    diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 4657bb1d..ce39bc28 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -102,11 +102,7 @@ private module Parsers premium = false premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } - author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| - badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") - end - author_verified = (author_verified_badge && author_verified_badge.size > 0) item_contents["badges"]?.try &.as_a.each do |badge| b = badge["metadataBadgeRenderer"] case b["label"].as_s @@ -133,7 +129,6 @@ private module Parsers live_now: live_now, premium: premium, premiere_timestamp: premiere_timestamp, - author_verified: author_verified || false, }) end @@ -161,11 +156,7 @@ private module Parsers private def self.parse(item_contents, author_fallback) author = extract_text(item_contents["title"]) || author_fallback.name author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id - author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| - badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") - end - author_verified = (author_verified_badge && author_verified_badge.size > 0) author_thumbnail = HelperExtractors.get_thumbnails(item_contents) # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. # Always simpleText @@ -188,7 +179,6 @@ private module Parsers video_count: video_count, description_html: description_html, auto_generated: auto_generated, - author_verified: author_verified || false, }) end @@ -216,23 +206,18 @@ private module Parsers private def self.parse(item_contents, author_fallback) title = extract_text(item_contents["title"]) || "" plid = item_contents["playlistId"]?.try &.as_s || "" - author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| - badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") - end - author_verified = (author_verified_badge && author_verified_badge.size > 0) video_count = HelperExtractors.get_video_count(item_contents) playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents) SearchPlaylist.new({ - title: title, - id: plid, - author: author_fallback.name, - ucid: author_fallback.id, - video_count: video_count, - videos: [] of SearchPlaylistVideo, - thumbnail: playlist_thumbnail, - author_verified: author_verified || false, + title: title, + id: plid, + author: author_fallback.name, + ucid: author_fallback.id, + video_count: video_count, + videos: [] of SearchPlaylistVideo, + thumbnail: playlist_thumbnail, }) end @@ -266,11 +251,7 @@ private module Parsers author_info = item_contents.dig?("shortBylineText", "runs", 0) author = author_info.try &.["text"].as_s || author_fallback.name author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id - author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| - badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") - end - author_verified = (author_verified_badge && author_verified_badge.size > 0) videos = item_contents["videos"]?.try &.as_a.map do |v| v = v["childVideoRenderer"] v_title = v.dig?("title", "simpleText").try &.as_s || "" @@ -286,14 +267,13 @@ private module Parsers # TODO: item_contents["publishedTimeText"]? SearchPlaylist.new({ - title: title, - id: plid, - author: author, - ucid: author_id, - video_count: video_count, - videos: videos, - thumbnail: playlist_thumbnail, - author_verified: author_verified || false, + title: title, + id: plid, + author: author, + ucid: author_id, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail, }) end -- cgit v1.2.3 From 96afc1a45d1e8df0f00d6c4cde9e9744cc9f1fcd Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 1 May 2022 13:40:02 +0200 Subject: Revert html escaping of backtrace --- src/invidious/helpers/errors.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index 2eab6263..b80dcdaf 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -46,7 +46,7 @@ def error_template_helper(env : HTTP::Server::Context, status_code : Int32, exce TEXT - issue_template += github_details("Backtrace", HTML.escape(exception.inspect_with_backtrace)) + issue_template += github_details("Backtrace", exception.inspect_with_backtrace) # URLs for the error message below url_faq = "https://github.com/iv-org/documentation/blob/master/docs/faq.md" -- cgit v1.2.3 From 7f2176d7fcc8e65b5eab97e991b8b853a952a0a0 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 1 May 2022 17:00:56 +0200 Subject: Add 'targetDurationSec' and 'maxDvrDurationSec' to videos API --- src/invidious/videos.cr | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 27c2b6d1..8a6a0f1a 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -413,6 +413,10 @@ struct Video end end + # Livestream chunk infos + json.field "targetDurationSec", fmt["targetDurationSec"].as_i if fmt.has_key?("targetDurationSec") + json.field "maxDvrDurationSec", fmt["maxDvrDurationSec"].as_i if fmt.has_key?("maxDvrDurationSec") + # Audio-related data json.field "audioQuality", fmt["audioQuality"] if fmt.has_key?("audioQuality") json.field "audioSampleRate", fmt["audioSampleRate"].as_s.to_i if fmt.has_key?("audioSampleRate") -- cgit v1.2.3 From 6a02dd88428491a4aad1ec80ed586826316cdf35 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 1 May 2022 17:42:53 +0200 Subject: Fix broken hashtag links --- src/invidious/comments.cr | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index c6e7fd17..71c16eb4 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -602,9 +602,11 @@ def content_to_comment_html(content) text = %(#{"youtube.com/watch?v=#{video_id}"}) end elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s - if text.starts_with?(/\s?@/) - # Handle "pings" in comments differently - # See: https://github.com/iv-org/invidious/issues/3038 + if text.starts_with?(/\s?[@#]/) + # Handle "pings" in comments and hasthags differently + # See: + # - https://github.com/iv-org/invidious/issues/3038 + # - https://github.com/iv-org/invidious/issues/3062 text = %(#{text}) else text = %(#{reduce_uri(url)}) -- cgit v1.2.3 From e690e166b0df203887715a0ce5c160cdb9f34054 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 1 May 2022 18:48:08 +0200 Subject: Fix javascript:void(0) instead of youtu.be links --- src/invidious/comments.cr | 29 +++++++++++++++++++---------- src/invidious/videos.cr | 2 +- src/invidious/yt_backend/extractors.cr | 2 +- 3 files changed, 21 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 71c16eb4..8e0d8a96 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -143,7 +143,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b node_comment = node["commentRenderer"] end - content_html = node_comment["contentText"]?.try { |t| parse_content(t) } || "" + content_html = node_comment["contentText"]?.try { |t| parse_content(t, id) } || "" author = node_comment["authorText"]?.try &.["simpleText"]? || "" json.field "author", author @@ -554,12 +554,12 @@ def fill_links(html, scheme, host) return html.to_xml(options: XML::SaveOptions::NO_DECL) end -def parse_content(content : JSON::Any) : String +def parse_content(content : JSON::Any, video_id : String? = "") : String content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || - content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r).try &.to_s.gsub("\n", "
    ") } || "" + content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r, video_id).try &.to_s.gsub("\n", "
    ") } || "" end -def content_to_comment_html(content) +def content_to_comment_html(content, video_id : String? = "") comment_html = content.map do |run| text = HTML.escape(run["text"].as_s) @@ -593,13 +593,22 @@ def content_to_comment_html(content) text = %(#{reduce_uri(displayed_url)}) elsif watch_endpoint = run["navigationEndpoint"]["watchEndpoint"]? - length_seconds = watch_endpoint["startTimeSeconds"]? - video_id = watch_endpoint["videoId"].as_s - - if length_seconds && length_seconds.as_i >= 0 - text = %(#{text}) + start_time = watch_endpoint["startTimeSeconds"]?.try &.as_i + link_video_id = watch_endpoint["videoId"].as_s + + url = "/watch?v=#{link_video_id}" + url += "&t=#{start_time}" if !start_time.nil? + + # If the current video ID (passed through from the caller function) + # is the same as the video ID in the link, add HTML attributes for + # the JS handler function that bypasses page reload. + # + # See: https://github.com/iv-org/invidious/issues/3063 + if link_video_id == video_id + start_time ||= 0 + text = %(#{reduce_uri(text)}) else - text = %(#{"youtube.com/watch?v=#{video_id}"}) + text = %(#{text}) end elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s if text.starts_with?(/\s?[@#]/) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 27c2b6d1..c007a07b 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1039,7 +1039,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ # Description description_html = video_secondary_renderer.try &.dig?("description", "runs") - .try &.as_a.try { |t| content_to_comment_html(t) } + .try &.as_a.try { |t| content_to_comment_html(t, video_id) } params["descriptionHtml"] = JSON::Any.new(description_html || "

    ") diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index ce39bc28..f6229a9b 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -69,7 +69,7 @@ private module Parsers # TODO change default value to nil and typical encoding type to tuple storing type (watchers, views, etc) # and count view_count = item_contents.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 - description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t) } || "" + description_html = item_contents["descriptionSnippet"]?.try { |t| parse_content(t, video_id) } || "" # The length information generally exist in "lengthText". However, the info can sometimes # be retrieved from "thumbnailOverlays" (e.g when the video is a "shorts" one). -- cgit v1.2.3 From f5fb4c6c64da58415dafba34087fa7dd9c11509a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 1 May 2022 21:10:43 +0200 Subject: Apply 2859.diff --- src/invidious/channels/about.cr | 7 ++++- src/invidious/comments.cr | 8 ++++- src/invidious/helpers/serialized_yt_data.cr | 7 ++++- src/invidious/routes/feeds.cr | 1 + src/invidious/videos.cr | 14 +++++++++ src/invidious/views/channel.ecr | 2 +- src/invidious/views/community.ecr | 2 +- src/invidious/views/components/item.ecr | 6 ++-- src/invidious/views/playlists.ecr | 2 +- src/invidious/views/watch.ecr | 6 ++-- src/invidious/yt_backend/extractors.cr | 48 ++++++++++++++++++++--------- 11 files changed, 77 insertions(+), 26 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 4f82a0f1..d48fd1fb 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -12,7 +12,8 @@ record AboutChannel, joined : Time, is_family_friendly : Bool, allowed_regions : Array(String), - tabs : Array(String) + tabs : Array(String), + verified : Bool record AboutRelatedChannel, ucid : String, @@ -70,6 +71,9 @@ def get_about_info(ucid, locale) : AboutChannel # if banner.includes? "channels/c4/default_banner" # banner = nil # end + # author_verified_badges = initdata["header"]?.try &.["c4TabbedHeaderRenderer"]?.try &.["badges"]? + author_verified_badge = initdata["header"].dig?("c4TabbedHeaderRenderer", "badges", 0, "metadataBadgeRenderer", "tooltip") + author_verified = (author_verified_badge && author_verified_badge == "Verified") description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" description_html = HTML.escape(description) @@ -128,6 +132,7 @@ def get_about_info(ucid, locale) : AboutChannel is_family_friendly: is_family_friendly, allowed_regions: allowed_regions, tabs: tabs, + verified: author_verified || false, ) end diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index c6e7fd17..3ae49aa6 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -146,6 +146,8 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b content_html = node_comment["contentText"]?.try { |t| parse_content(t) } || "" author = node_comment["authorText"]?.try &.["simpleText"]? || "" + json.field "verified", (node_comment["authorCommentBadge"]? != nil) + json.field "author", author json.field "authorThumbnails" do json.array do @@ -329,7 +331,11 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end author_name = HTML.escape(child["author"].as_s) - + if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool + author_name += " " + elsif child["verified"]?.try &.as_bool + author_name += " " + end html << <<-END_HTML
    diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index bfbc237c..3918bd13 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -12,6 +12,7 @@ struct SearchVideo property live_now : Bool property premium : Bool property premiere_timestamp : Time? + property author_verified : Bool def to_xml(auto_generated, query_params, xml : XML::Builder) query_params["v"] = self.id @@ -129,6 +130,7 @@ struct SearchPlaylist property video_count : Int32 property videos : Array(SearchPlaylistVideo) property thumbnail : String? + property author_verified : Bool def to_json(locale : String?, json : JSON::Builder) json.object do @@ -141,6 +143,8 @@ struct SearchPlaylist json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" + json.field "authorVerified", self.author_verified + json.field "videoCount", self.video_count json.field "videos" do json.array do @@ -182,6 +186,7 @@ struct SearchChannel property video_count : Int32 property description_html : String property auto_generated : Bool + property author_verified : Bool def to_json(locale : String?, json : JSON::Builder) json.object do @@ -189,7 +194,7 @@ struct SearchChannel json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" - + json.field "authorVerified", self.author_verified json.field "authorThumbnails" do json.array do qualities = {32, 48, 76, 100, 176, 512} diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index f7f7b426..b5b58399 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -182,6 +182,7 @@ module Invidious::Routes::Feeds paid: false, premium: false, premiere_timestamp: nil, + author_verified: false, # ¯\_(ツ)_/¯ }) end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 8a6a0f1a..b16955b1 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -613,6 +613,10 @@ struct Video info["authorThumbnail"]?.try &.as_s || "" end + def author_verified : Bool + info["authorVerified"].try &.as_bool || false + end + def sub_count_text : String info["subCountText"]?.try &.as_s || "-" end @@ -864,6 +868,12 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? .try &.dig?("runs", 0) author = channel_info.try &.dig?("text") + author_verified_badge = related["ownerBadges"]?.try do |badges_array| + badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") + end + + author_verified = (author_verified_badge && author_verified_badge.size > 0).to_s + ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } # "4,088,033 views", only available on compact renderer @@ -887,6 +897,7 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? "length_seconds" => JSON::Any.new(length || "0"), "view_count" => JSON::Any.new(view_count || "0"), "short_view_count" => JSON::Any.new(short_view_count || "0"), + "author_verified" => JSON::Any.new(author_verified), } end @@ -1081,6 +1092,9 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url") + author_verified_badge = author_info.try &.dig?("badges", 0, "metadataBadgeRenderer", "tooltip") + params["authorVerified"] = JSON::Any.new((author_verified_badge && author_verified_badge == "Verified")) + params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 40b553a9..92f81ee4 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -20,7 +20,7 @@
    - <%= author %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index f0add06b..3bc29e55 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -19,7 +19,7 @@
    - <%= author %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index ce7af783..fb7ad1dc 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -8,7 +8,7 @@ "/> <% end %> -

    <%= HTML.escape(item.author) %>

    +

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -30,7 +30,7 @@

    <%= HTML.escape(item.title) %>

    -

    <%= HTML.escape(item.author) %>

    +

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

    <% when MixVideo %> @@ -142,7 +142,7 @@
    <% endpoint_params = "?v=#{item.id}" %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index 12dba088..c8718e7b 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -19,7 +19,7 @@
    - <%= author %> + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 2e493f4c..8b6eb903 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -207,7 +207,7 @@ we're going to need to do it here in order to allow for translations. <% if !video.author_thumbnail.empty? %> <% end %> - <%= author %> + <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %>
    @@ -281,9 +281,9 @@ we're going to need to do it here in order to allow for translations.
    <% if rv["ucid"]? %> - "><%= rv["author"]? %> + "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> <% else %> - <%= rv["author"]? %> + <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> <% end %>
    diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index ce39bc28..4657bb1d 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -102,7 +102,11 @@ private module Parsers premium = false premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } + author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| + badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") + end + author_verified = (author_verified_badge && author_verified_badge.size > 0) item_contents["badges"]?.try &.as_a.each do |badge| b = badge["metadataBadgeRenderer"] case b["label"].as_s @@ -129,6 +133,7 @@ private module Parsers live_now: live_now, premium: premium, premiere_timestamp: premiere_timestamp, + author_verified: author_verified || false, }) end @@ -156,7 +161,11 @@ private module Parsers private def self.parse(item_contents, author_fallback) author = extract_text(item_contents["title"]) || author_fallback.name author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id + author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| + badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") + end + author_verified = (author_verified_badge && author_verified_badge.size > 0) author_thumbnail = HelperExtractors.get_thumbnails(item_contents) # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. # Always simpleText @@ -179,6 +188,7 @@ private module Parsers video_count: video_count, description_html: description_html, auto_generated: auto_generated, + author_verified: author_verified || false, }) end @@ -206,18 +216,23 @@ private module Parsers private def self.parse(item_contents, author_fallback) title = extract_text(item_contents["title"]) || "" plid = item_contents["playlistId"]?.try &.as_s || "" + author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| + badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") + end + author_verified = (author_verified_badge && author_verified_badge.size > 0) video_count = HelperExtractors.get_video_count(item_contents) playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents) SearchPlaylist.new({ - title: title, - id: plid, - author: author_fallback.name, - ucid: author_fallback.id, - video_count: video_count, - videos: [] of SearchPlaylistVideo, - thumbnail: playlist_thumbnail, + title: title, + id: plid, + author: author_fallback.name, + ucid: author_fallback.id, + video_count: video_count, + videos: [] of SearchPlaylistVideo, + thumbnail: playlist_thumbnail, + author_verified: author_verified || false, }) end @@ -251,7 +266,11 @@ private module Parsers author_info = item_contents.dig?("shortBylineText", "runs", 0) author = author_info.try &.["text"].as_s || author_fallback.name author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id + author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| + badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") + end + author_verified = (author_verified_badge && author_verified_badge.size > 0) videos = item_contents["videos"]?.try &.as_a.map do |v| v = v["childVideoRenderer"] v_title = v.dig?("title", "simpleText").try &.as_s || "" @@ -267,13 +286,14 @@ private module Parsers # TODO: item_contents["publishedTimeText"]? SearchPlaylist.new({ - title: title, - id: plid, - author: author, - ucid: author_id, - video_count: video_count, - videos: videos, - thumbnail: playlist_thumbnail, + title: title, + id: plid, + author: author, + ucid: author_id, + video_count: video_count, + videos: videos, + thumbnail: playlist_thumbnail, + author_verified: author_verified || false, }) end -- cgit v1.2.3 From b84ce6a5568429ffa30d993a8cd0410cfb72449b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 1 May 2022 21:11:12 +0200 Subject: Fix "cast from Nil to Bool failed" --- src/invidious/videos.cr | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index b16955b1..7c1f68a8 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -614,7 +614,7 @@ struct Video end def author_verified : Bool - info["authorVerified"].try &.as_bool || false + info["authorVerified"]?.try &.as_bool || false end def sub_count_text : String @@ -1093,7 +1093,8 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url") author_verified_badge = author_info.try &.dig?("badges", 0, "metadataBadgeRenderer", "tooltip") - params["authorVerified"] = JSON::Any.new((author_verified_badge && author_verified_badge == "Verified")) + author_verified = (!author_verified_badge.nil? && author_verified_badge == "Verified") + params["authorVerified"] = JSON::Any.new(author_verified) params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") -- cgit v1.2.3 From a122286d48f96a929856a74bce4738bf0695dd66 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 2 May 2022 19:34:08 +0200 Subject: Add Hindi to i18n.cr --- src/invidious/helpers/i18n.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 982b97d8..3f987b4d 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -14,6 +14,7 @@ LOCALES_LIST = { "fi" => "Suomi", # Finnish "fr" => "Français", # French "he" => "עברית", # Hebrew + "hi" => "हिन्दी", # Hindi "hr" => "Hrvatski", # Croatian "hu-HU" => "Magyar Nyelv", # Hungarian "id" => "Bahasa Indonesia", # Indonesian -- cgit v1.2.3 From b0342b744956809851399a3a5fa735a7b7f4f5ae Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 1 May 2022 19:08:11 +0200 Subject: Other minor fixes --- assets/js/handlers.js | 17 ++++++++++------- src/invidious/comments.cr | 22 ++++++++-------------- 2 files changed, 18 insertions(+), 21 deletions(-) (limited to 'src') diff --git a/assets/js/handlers.js b/assets/js/handlers.js index 3224e668..f6617b60 100644 --- a/assets/js/handlers.js +++ b/assets/js/handlers.js @@ -13,20 +13,23 @@ // For dynamically inserted elements document.addEventListener('click', function (e) { if (!e || !e.target) { return; } - e = e.target; - var handler_name = e.getAttribute('data-onclick'); + + var t = e.target; + var handler_name = t.getAttribute('data-onclick'); + switch (handler_name) { case 'jump_to_time': - var time = e.getAttribute('data-jump-time'); + e.preventDefault(); + var time = t.getAttribute('data-jump-time'); player.currentTime(time); break; case 'get_youtube_replies': - var load_more = e.getAttribute('data-load-more') !== null; - var load_replies = e.getAttribute('data-load-replies') !== null; - get_youtube_replies(e, load_more, load_replies); + var load_more = t.getAttribute('data-load-more') !== null; + var load_replies = t.getAttribute('data-load-replies') !== null; + get_youtube_replies(t, load_more, load_replies); break; case 'toggle_parent': - toggle_parent(e); + toggle_parent(t); break; default: break; diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 8e0d8a96..91ea8607 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -560,30 +560,21 @@ def parse_content(content : JSON::Any, video_id : String? = "") : String end def content_to_comment_html(content, video_id : String? = "") - comment_html = content.map do |run| + html_array = content.map do |run| text = HTML.escape(run["text"].as_s) - if run["bold"]? - text = "#{text}" - end - - if run["italics"]? - text = "#{text}" - end - if run["navigationEndpoint"]? if url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s url = URI.parse(url) - displayed_url = url + displayed_url = text if url.host == "youtu.be" url = "/watch?v=#{url.request_target.lstrip('/')}" - displayed_url = "youtube.com#{url}" elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") if url.path == "/redirect" # Sometimes, links can be corrupted (why?) so make sure to fallback # nicely. See https://github.com/iv-org/invidious/issues/2682 - url = HTTP::Params.parse(url.query.not_nil!)["q"]? || "" + url = url.query_params["q"]? || "" displayed_url = url else url = url.request_target @@ -623,10 +614,13 @@ def content_to_comment_html(content, video_id : String? = "") end end + text = "#{text}" if run["bold"]? + text = "#{text}" if run["italics"]? + text - end.join("").delete('\ufeff') + end - return comment_html + return html_array.join("").delete('\ufeff') end def produce_comment_continuation(video_id, cursor = "", sort_by = "top") -- cgit v1.2.3 From 9bd9dcc41c1d2d9b0fcacc7c2248d852cd2f8ac3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 4 May 2022 22:36:31 +0200 Subject: Add Slovenian to i18n.cr --- src/invidious/helpers/i18n.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 3f987b4d..9d3c4e8b 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -31,6 +31,7 @@ LOCALES_LIST = { "pt-PT" => "Português de Portugal", # Portuguese (Portugal) "ro" => "Română", # Romanian "ru" => "Русский", # Russian + "sl" => "Slovenščina", # Slovenian "sq" => "Shqip", # Albanian "sr" => "Srpski (latinica)", # Serbian (Latin) "sr_Cyrl" => "Српски (ћирилица)", # Serbian (Cyrillic) -- cgit v1.2.3 From 7dd699370fae20c69119a4117468b1d999a2752a Mon Sep 17 00:00:00 2001 From: meow Date: Fri, 6 May 2022 04:46:59 +0300 Subject: js code rewrite. Created _helpers.js with XHR and storage wrapper --- assets/js/_helpers.js | 218 ++++++++++ assets/js/community.js | 80 ++-- assets/js/embed.js | 103 ++--- assets/js/handlers.js | 137 +++--- assets/js/notifications.js | 94 ++--- assets/js/player.js | 308 ++++++-------- assets/js/playlist_widget.js | 52 +-- assets/js/subscribe_widget.js | 92 +--- assets/js/themes.js | 48 +-- assets/js/watch.js | 464 ++++++++------------- assets/js/watched_widget.js | 39 +- src/invidious/comments.cr | 2 +- src/invidious/views/add_playlist_items.ecr | 1 + src/invidious/views/community.ecr | 1 + src/invidious/views/components/player.ecr | 1 + .../views/components/subscribe_widget.ecr | 1 + src/invidious/views/embed.ecr | 1 + src/invidious/views/feeds/history.ecr | 1 + src/invidious/views/feeds/subscriptions.ecr | 1 + src/invidious/views/licenses.ecr | 14 + src/invidious/views/playlist.ecr | 1 + src/invidious/views/template.ecr | 1 + src/invidious/views/watch.ecr | 2 + 23 files changed, 748 insertions(+), 914 deletions(-) create mode 100644 assets/js/_helpers.js (limited to 'src') diff --git a/assets/js/_helpers.js b/assets/js/_helpers.js new file mode 100644 index 00000000..04576348 --- /dev/null +++ b/assets/js/_helpers.js @@ -0,0 +1,218 @@ +'use strict'; +// Contains only auxiliary methods +// May be included and executed unlimited number of times without any consequences + +// Polyfills for IE11 +Array.prototype.find = Array.prototype.find || function (condition) { + return this.filter(condition)[0]; +}; +Array.from = Array.from || function (source) { + return Array.prototype.slice.call(source); +}; +NodeList.prototype.forEach = NodeList.prototype.forEach || function (callback) { + Array.from(this).forEach(callback); +}; +String.prototype.includes = String.prototype.includes || function (searchString) { + return this.indexOf(searchString) >= 0; +}; +String.prototype.startsWith = String.prototype.startsWith || function (prefix) { + return this.substr(0, prefix.length) === prefix; +}; +Math.sign = Math.sign || function(x) { + x = +x; + if (!x) return x; // 0 and NaN + return x > 0 ? 1 : -1; +}; + +// Monstrous global variable for handy code +helpers = helpers || { + /** + * https://en.wikipedia.org/wiki/Clamping_(graphics) + * @param {Number} num Source number + * @param {Number} min Low border + * @param {Number} max High border + * @returns {Number} Clamped value + */ + clamp: function (num, min, max) { + if (max < min) { + var t = max; max = min; min = t; // swap max and min + } + + if (max > num) + return max; + if (min < num) + return min; + return num; + }, + + /** @private */ + _xhr: function (method, url, options, callbacks) { + const xhr = new XMLHttpRequest(); + xhr.open(method, url); + + // Default options + xhr.responseType = 'json'; + xhr.timeout = 10000; + // Default options redefining + if (options.responseType) + xhr.responseType = options.responseType; + if (options.timeout) + xhr.timeout = options.timeout; + + if (method === 'POST') + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); + + xhr.onreadystatechange = function () { + if (xhr.readyState === XMLHttpRequest.DONE) { + if (xhr.status === 200) + if (callbacks.on200) + callbacks.on200(xhr.response); + else + if (callbacks.onNon200) + callbacks.onNon200(xhr); + } + }; + + xhr.ontimeout = function () { + if (callbacks.onTimeout) + callbacks.onTimeout(xhr); + }; + + xhr.onerror = function () { + if (callbacks.onError) + callbacks.onError(xhr); + }; + + if (options.payload) + xhr.send(options.payload); + else + xhr.send(); + }, + /** @private */ + _xhrRetry(method, url, options, callbacks) { + if (options.retries <= 0) { + console.warn('Failed to pull', options.entity_name); + if (callbacks.onTotalFail) + callbacks.onTotalFail(); + return; + } + helpers.xhr(method, url, options, callbacks); + }, + /** + * @callback callbackXhrOn200 + * @param {Object} response - xhr.response + */ + /** + * @callback callbackXhrError + * @param {XMLHttpRequest} xhr + */ + /** + * @param {'GET'|'POST'} method - 'GET' or 'POST' + * @param {String} url - URL to send request to + * @param {Object} options - other XHR options + * @param {XMLHttpRequestBodyInit} [options.payload=null] - payload for POST-requests + * @param {'arraybuffer'|'blob'|'document'|'json'|'text'} [options.responseType=json] + * @param {Number} [options.timeout=10000] + * @param {Number} [options.retries=1] + * @param {String} [options.entity_name='unknown'] - string to log + * @param {Number} [options.retry_timeout=1000] + * @param {Object} callbacks - functions to execute on events fired + * @param {callbackXhrOn200} [callbacks.on200] + * @param {callbackXhrError} [callbacks.onNon200] + * @param {callbackXhrError} [callbacks.onTimeout] + * @param {callbackXhrError} [callbacks.onError] + * @param {callbackXhrError} [callbacks.onTotalFail] - if failed after all retries + */ + xhr(method, url, options, callbacks) { + if (options.retries > 1) { + helpers._xhr(method, url, options, callbacks); + return; + } + + if (!options.entity_name) options.entity_name = 'unknown'; + if (!options.retry_timeout) options.retry_timeout = 1; + const retries_total = options.retries; + + const retry = function () { + console.warn('Pulling ' + options.entity_name + ' failed... ' + options.retries + '/' + retries_total); + setTimeout(function () { + options.retries--; + helpers._xhrRetry(method, url, options, callbacks); + }, options.retry_timeout); + }; + + if (callbacks.onError) + callbacks._onError = callbacks.onError; + callbacks.onError = function (xhr) { + if (callbacks._onError) + callbacks._onError(); + retry(); + }; + + if (callbacks.onTimeout) + callbacks._onTimeout = callbacks.onTimeout; + callbacks.onTimeout = function (xhr) { + if (callbacks._onTimeout) + callbacks._onTimeout(); + retry(); + }; + helpers._xhrRetry(method, url, options, callbacks); + }, + + /** + * @typedef {Object} invidiousStorage + * @property {(key:String) => Object|null} get + * @property {(key:String, value:Object) => null} set + * @property {(key:String) => null} remove + */ + + /** + * Universal storage proxy. Uses inside localStorage or cookies + * @type {invidiousStorage} + */ + storage: (function () { + // access to localStorage throws exception in Tor Browser, so try is needed + let localStorageIsUsable = false; + try{localStorageIsUsable = !!localStorage.setItem;}catch(e){} + + if (localStorageIsUsable) { + return { + get: function (key) { return localStorage[key]; }, + set: function (key, value) { localStorage[key] = value; }, + remove: function (key) { localStorage.removeItem(key); } + }; + } + + console.info('Storage: localStorage is disabled or unaccessible trying cookies'); + return { + get: function (key) { + const cookiePrefix = key + '='; + function findCallback(cookie) {return cookie.startsWith(cookiePrefix);} + const matchedCookie = document.cookie.split(';').find(findCallback); + if (matchedCookie) + return matchedCookie.replace(cookiePrefix, ''); + return null; + }, + set: function (key, value) { + const cookie_data = encodeURIComponent(JSON.stringify(value)); + + // Set expiration in 2 year + const date = new Date(); + date.setTime(date.getTime() + 2*365.25*24*60*60); + + const ip_regex = /^((\d+\.){3}\d+|[A-Fa-f0-9]*:[A-Fa-f0-9:]*:[A-Fa-f0-9:]+)$/; + let domain_used = location.hostname; + + // Fix for a bug in FF where the leading dot in the FQDN is not ignored + if (domain_used.charAt(0) !== '.' && !ip_regex.test(domain_used) && domain_used !== 'localhost') + domain_used = '.' + location.hostname; + + document.cookie = key + '=' + cookie_data + '; SameSite=Strict; path=/; domain=' + + domain_used + '; expires=' + date.toGMTString() + ';'; + }, + remove: function (key) { + document.cookie = key + '=; Max-Age=0'; + } + }; + })() +}; diff --git a/assets/js/community.js b/assets/js/community.js index 44066a58..33e2e3ed 100644 --- a/assets/js/community.js +++ b/assets/js/community.js @@ -1,13 +1,6 @@ 'use strict'; var community_data = JSON.parse(document.getElementById('community_data').textContent); -String.prototype.supplant = function (o) { - return this.replace(/{([^{}]*)}/g, function (a, b) { - var r = o[b]; - return typeof r === 'string' || typeof r === 'number' ? r : a; - }); -}; - function hide_youtube_replies(event) { var target = event.target; @@ -38,13 +31,6 @@ function show_youtube_replies(event) { target.setAttribute('data-sub-text', sub_text); } -function number_with_separator(val) { - while (/(\d+)(\d{3})/.test(val.toString())) { - val = val.toString().replace(/(\d+)(\d{3})/, '$1' + ',' + '$2'); - } - return val; -} - function get_youtube_replies(target, load_more) { var continuation = target.getAttribute('data-continuation'); @@ -52,53 +38,45 @@ function get_youtube_replies(target, load_more) { var fallback = body.innerHTML; body.innerHTML = '

    '; - + var url = '/api/v1/channels/comments/' + community_data.ucid + '?format=html' + '&hl=' + community_data.preferences.locale + '&thin_mode=' + community_data.preferences.thin_mode + '&continuation=' + continuation; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - if (load_more) { - body = body.parentNode.parentNode; - body.removeChild(body.lastElementChild); - body.innerHTML += xhr.response.contentHtml; - } else { - body.removeChild(body.lastElementChild); + helpers.xhr('GET', url, {}, { + on200: function (response) { + if (load_more) { + body = body.parentNode.parentNode; + body.removeChild(body.lastElementChild); + body.innerHTML += response.contentHtml; + } else { + body.removeChild(body.lastElementChild); - var p = document.createElement('p'); - var a = document.createElement('a'); - p.appendChild(a); + var p = document.createElement('p'); + var a = document.createElement('a'); + p.appendChild(a); - a.href = 'javascript:void(0)'; - a.onclick = hide_youtube_replies; - a.setAttribute('data-sub-text', community_data.hide_replies_text); - a.setAttribute('data-inner-text', community_data.show_replies_text); - a.innerText = community_data.hide_replies_text; + a.href = 'javascript:void(0)'; + a.onclick = hide_youtube_replies; + a.setAttribute('data-sub-text', community_data.hide_replies_text); + a.setAttribute('data-inner-text', community_data.show_replies_text); + a.innerText = community_data.hide_replies_text; - var div = document.createElement('div'); - div.innerHTML = xhr.response.contentHtml; + var div = document.createElement('div'); + div.innerHTML = response.contentHtml; - body.appendChild(p); - body.appendChild(div); - } - } else { - body.innerHTML = fallback; + body.appendChild(p); + body.appendChild(div); } + }, + onNon200: function (xhr) { + body.innerHTML = fallback; + }, + onTimeout: function (xhr) { + console.warn('Pulling comments failed'); + body.innerHTML = fallback; } - }; - - xhr.ontimeout = function () { - console.warn('Pulling comments failed.'); - body.innerHTML = fallback; - }; - - xhr.send(); + }); } diff --git a/assets/js/embed.js b/assets/js/embed.js index 7e9ac605..b11b5e5a 100644 --- a/assets/js/embed.js +++ b/assets/js/embed.js @@ -1,14 +1,7 @@ 'use strict'; var video_data = JSON.parse(document.getElementById('video_data').textContent); -function get_playlist(plid, retries) { - if (retries === undefined) retries = 5; - - if (retries <= 0) { - console.warn('Failed to pull playlist'); - return; - } - +function get_playlist(plid) { var plid_url; if (plid.startsWith('RD')) { plid_url = '/api/v1/mixes/' + plid + @@ -21,85 +14,49 @@ function get_playlist(plid, retries) { '&format=html&hl=' + video_data.preferences.locale; } - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', plid_url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - if (xhr.response.nextVideo) { - player.on('ended', function () { - var url = new URL('https://example.com/embed/' + xhr.response.nextVideo); - - url.searchParams.set('list', plid); - if (!plid.startsWith('RD')) { - url.searchParams.set('index', xhr.response.index); - } - - 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.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); - } - - location.assign(url.pathname + url.search); - }); - } - } + helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, { + on200: function (response) { + if (!response.nextVideo) + return; + + player.on('ended', function () { + var url = new URL('https://example.com/embed/' + response.nextVideo); + + url.searchParams.set('list', plid); + if (!plid.startsWith('RD')) + url.searchParams.set('index', response.index); + 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.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); + + location.assign(url.pathname + url.search); + }); } - }; - - xhr.onerror = function () { - console.warn('Pulling playlist failed... ' + retries + '/5'); - setTimeout(function () { get_playlist(plid, retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - console.warn('Pulling playlist failed... ' + retries + '/5'); - get_playlist(plid, retries - 1); - }; - - xhr.send(); + }); } -window.addEventListener('load', function (e) { +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) { + if (video_data.params.autoplay || video_data.params.continue_autoplay) url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.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) { + 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) { + if (video_data.params.local !== video_data.preferences.local) url.searchParams.set('local', video_data.params.local); - } - - if (video_data.video_series.length !== 0) { + if (video_data.video_series.length !== 0) url.searchParams.set('playlist', video_data.video_series.join(',')); - } location.assign(url.pathname + url.search); }); diff --git a/assets/js/handlers.js b/assets/js/handlers.js index f6617b60..438832b1 100644 --- a/assets/js/handlers.js +++ b/assets/js/handlers.js @@ -1,8 +1,6 @@ 'use strict'; (function () { - var n2a = function (n) { return Array.prototype.slice.call(n); }; - var video_player = document.getElementById('player_html5_api'); if (video_player) { video_player.onmouseenter = function () { video_player['data-title'] = video_player['title']; video_player['title'] = ''; }; @@ -11,8 +9,8 @@ } // For dynamically inserted elements - document.addEventListener('click', function (e) { - if (!e || !e.target) { return; } + addEventListener('click', function (e) { + if (!e || !e.target) return; var t = e.target; var handler_name = t.getAttribute('data-onclick'); @@ -29,6 +27,7 @@ get_youtube_replies(t, load_more, load_replies); break; case 'toggle_parent': + e.preventDefault(); toggle_parent(t); break; default: @@ -36,118 +35,98 @@ } }); - n2a(document.querySelectorAll('[data-mouse="switch_classes"]')).forEach(function (e) { - var classes = e.getAttribute('data-switch-classes').split(','); - var ec = classes[0]; - var lc = classes[1]; - var onoff = function (on, off) { - var cs = e.getAttribute('class'); - cs = cs.split(off).join(on); - e.setAttribute('class', cs); - }; - e.onmouseenter = function () { onoff(ec, lc); }; - e.onmouseleave = function () { onoff(lc, ec); }; + document.querySelectorAll('[data-mouse="switch_classes"]').forEach(function (el) { + var classes = el.getAttribute('data-switch-classes').split(','); + var classOnEnter = classes[0]; + var classOnLeave = classes[1]; + function toggle_classes(toAdd, toRemove) { + el.classList.add(toAdd); + el.classList.remove(toRemove); + } + el.onmouseenter = function () { toggle_classes(classOnEnter, classOnLeave); }; + el.onmouseleave = function () { toggle_classes(classOnLeave, classOnEnter); }; }); - n2a(document.querySelectorAll('[data-onsubmit="return_false"]')).forEach(function (e) { - e.onsubmit = function () { return false; }; + document.querySelectorAll('[data-onsubmit="return_false"]').forEach(function (el) { + el.onsubmit = function () { return false; }; }); - n2a(document.querySelectorAll('[data-onclick="mark_watched"]')).forEach(function (e) { - e.onclick = function () { mark_watched(e); }; + document.querySelectorAll('[data-onclick="mark_watched"]').forEach(function (el) { + el.onclick = function () { mark_watched(el); }; }); - n2a(document.querySelectorAll('[data-onclick="mark_unwatched"]')).forEach(function (e) { - e.onclick = function () { mark_unwatched(e); }; + document.querySelectorAll('[data-onclick="mark_unwatched"]').forEach(function (el) { + el.onclick = function () { mark_unwatched(el); }; }); - n2a(document.querySelectorAll('[data-onclick="add_playlist_video"]')).forEach(function (e) { - e.onclick = function () { add_playlist_video(e); }; + document.querySelectorAll('[data-onclick="add_playlist_video"]').forEach(function (el) { + el.onclick = function () { add_playlist_video(el); }; }); - n2a(document.querySelectorAll('[data-onclick="add_playlist_item"]')).forEach(function (e) { - e.onclick = function () { add_playlist_item(e); }; + document.querySelectorAll('[data-onclick="add_playlist_item"]').forEach(function (el) { + el.onclick = function () { add_playlist_item(el); }; }); - n2a(document.querySelectorAll('[data-onclick="remove_playlist_item"]')).forEach(function (e) { - e.onclick = function () { remove_playlist_item(e); }; + document.querySelectorAll('[data-onclick="remove_playlist_item"]').forEach(function (el) { + el.onclick = function () { remove_playlist_item(el); }; }); - n2a(document.querySelectorAll('[data-onclick="revoke_token"]')).forEach(function (e) { - e.onclick = function () { revoke_token(e); }; + document.querySelectorAll('[data-onclick="revoke_token"]').forEach(function (el) { + el.onclick = function () { revoke_token(el); }; }); - n2a(document.querySelectorAll('[data-onclick="remove_subscription"]')).forEach(function (e) { - e.onclick = function () { remove_subscription(e); }; + document.querySelectorAll('[data-onclick="remove_subscription"]').forEach(function (el) { + el.onclick = function () { remove_subscription(el); }; }); - n2a(document.querySelectorAll('[data-onclick="notification_requestPermission"]')).forEach(function (e) { - e.onclick = function () { Notification.requestPermission(); }; + document.querySelectorAll('[data-onclick="notification_requestPermission"]').forEach(function (el) { + el.onclick = function () { Notification.requestPermission(); }; }); - n2a(document.querySelectorAll('[data-onrange="update_volume_value"]')).forEach(function (e) { - var cb = function () { update_volume_value(e); }; - e.oninput = cb; - e.onchange = cb; + document.querySelectorAll('[data-onrange="update_volume_value"]').forEach(function (el) { + function update_volume_value() { + document.getElementById('volume-value').innerText = el.value; + } + el.oninput = update_volume_value; + el.onchange = update_volume_value; }); - function update_volume_value(element) { - document.getElementById('volume-value').innerText = element.value; - } function revoke_token(target) { var row = target.parentNode.parentNode.parentNode.parentNode.parentNode; row.style.display = 'none'; var count = document.getElementById('count'); - count.innerText = count.innerText - 1; + count.innerText = parseInt(count.innerText) - 1; - var referer = window.encodeURIComponent(document.location.href); var url = '/token_ajax?action_revoke_token=1&redirect=false' + - '&referer=' + referer + + '&referer=' + encodeURIComponent(location.href) + '&session=' + target.getAttribute('data-session'); - 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) { - count.innerText = parseInt(count.innerText) + 1; - row.style.display = ''; - } - } - }; - var csrf_token = target.parentNode.querySelector('input[name="csrf_token"]').value; - xhr.send('csrf_token=' + csrf_token); + var payload = 'csrf_token=' + target.parentNode.querySelector('input[name="csrf_token"]').value; + + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + count.innerText = parseInt(count.innerText) + 1; + row.style.display = ''; + } + }); } function remove_subscription(target) { var row = target.parentNode.parentNode.parentNode.parentNode.parentNode; row.style.display = 'none'; var count = document.getElementById('count'); - count.innerText = count.innerText - 1; + count.innerText = parseInt(count.innerText) - 1; - var referer = window.encodeURIComponent(document.location.href); var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' + - '&referer=' + referer + + '&referer=' + encodeURIComponent(location.href) + '&c=' + target.getAttribute('data-ucid'); - 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) { - count.innerText = parseInt(count.innerText) + 1; - row.style.display = ''; - } - } - }; - var csrf_token = target.parentNode.querySelector('input[name="csrf_token"]').value; - xhr.send('csrf_token=' + csrf_token); + var payload = 'csrf_token=' + target.parentNode.querySelector('input[name="csrf_token"]').value; + + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + count.innerText = parseInt(count.innerText) + 1; + row.style.display = ''; + } + }); } // Handle keypresses - window.addEventListener('keydown', function (event) { + addEventListener('keydown', function (event) { // Ignore modifier keys if (event.ctrlKey || event.metaKey) return; diff --git a/assets/js/notifications.js b/assets/js/notifications.js index ec5f6dd3..f8cc750b 100644 --- a/assets/js/notifications.js +++ b/assets/js/notifications.js @@ -2,42 +2,20 @@ var notification_data = JSON.parse(document.getElementById('notification_data').textContent); var notifications, delivered; - -function get_subscriptions(callback, retries) { - if (retries === undefined) retries = 5; - - if (retries <= 0) { - return; - } - - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', '/api/v1/auth/subscriptions?fields=authorId', true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - var subscriptions = xhr.response; - callback(subscriptions); - } - } - }; - - xhr.onerror = function () { - console.warn('Pulling subscriptions failed... ' + retries + '/5'); - setTimeout(function () { get_subscriptions(callback, retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - console.warn('Pulling subscriptions failed... ' + retries + '/5'); - get_subscriptions(callback, retries - 1); - }; - - xhr.send(); +var notifications_substitution = { close: function () { } }; + +function get_subscriptions() { + helpers.xhr('GET', '/api/v1/auth/subscriptions?fields=authorId', { + retries: 5, + entity_name: 'subscriptions' + }, { + on200: create_notification_stream + }); } function create_notification_stream(subscriptions) { + // sse.js can't be replaced to EventSource in place as it lack support of payload and headers + // see https://developer.mozilla.org/en-US/docs/Web/API/EventSource/EventSource notifications = new SSE( '/api/v1/auth/notifications?fields=videoId,title,author,authorId,publishedText,published,authorThumbnails,liveNow', { withCredentials: true, @@ -49,9 +27,7 @@ function create_notification_stream(subscriptions) { var start_time = Math.round(new Date() / 1000); notifications.onmessage = function (event) { - if (!event.id) { - return; - } + if (!event.id) return; var notification = JSON.parse(event.data); console.info('Got notification:', notification); @@ -67,17 +43,17 @@ function create_notification_stream(subscriptions) { }); system_notification.onclick = function (event) { - window.open('/watch?v=' + event.currentTarget.tag, '_blank'); + open('/watch?v=' + event.currentTarget.tag, '_blank'); }; } delivered.push(notification.videoId); - localStorage.setItem('notification_count', parseInt(localStorage.getItem('notification_count') || '0') + 1); + helpers.storage.set('notification_count', parseInt(helpers.storage.get('notification_count') || '0') + 1); var notification_ticker = document.getElementById('notification_ticker'); - if (parseInt(localStorage.getItem('notification_count')) > 0) { + if (parseInt(helpers.storage.get('notification_count')) > 0) { notification_ticker.innerHTML = - '' + localStorage.getItem('notification_count') + ' '; + '' + helpers.storage.get('notification_count') + ' '; } else { notification_ticker.innerHTML = ''; @@ -91,35 +67,35 @@ function create_notification_stream(subscriptions) { function handle_notification_error(event) { console.warn('Something went wrong with notifications, trying to reconnect...'); - notifications = { close: function () { } }; - setTimeout(function () { get_subscriptions(create_notification_stream); }, 1000); + notifications = notifications_substitution; + setTimeout(get_subscriptions, 1000); } -window.addEventListener('load', function (e) { - localStorage.setItem('notification_count', document.getElementById('notification_count') ? document.getElementById('notification_count').innerText : '0'); +addEventListener('load', function (e) { + helpers.storage.set('notification_count', document.getElementById('notification_count') ? document.getElementById('notification_count').innerText : '0'); - if (localStorage.getItem('stream')) { - localStorage.removeItem('stream'); + if (helpers.storage.get('stream')) { + helpers.storage.remove('stream'); } else { setTimeout(function () { - if (!localStorage.getItem('stream')) { - notifications = { close: function () { } }; - localStorage.setItem('stream', true); - get_subscriptions(create_notification_stream); + if (!helpers.storage.get('stream')) { + notifications = notifications_substitution; + helpers.storage.set('stream', true); + get_subscriptions(); } }, Math.random() * 1000 + 50); } - window.addEventListener('storage', function (e) { + addEventListener('storage', function (e) { if (e.key === 'stream' && !e.newValue) { if (notifications) { - localStorage.setItem('stream', true); + helpers.storage.set('stream', true); } else { setTimeout(function () { - if (!localStorage.getItem('stream')) { - notifications = { close: function () { } }; - localStorage.setItem('stream', true); - get_subscriptions(create_notification_stream); + if (!helpers.storage.get('stream')) { + notifications = notifications_substitution; + helpers.storage.set('stream', true); + get_subscriptions(); } }, Math.random() * 1000 + 50); } @@ -137,8 +113,6 @@ window.addEventListener('load', function (e) { }); }); -window.addEventListener('unload', function (e) { - if (notifications) { - localStorage.removeItem('stream'); - } +addEventListener('unload', function (e) { + if (notifications) helpers.storage.remove('stream'); }); diff --git a/assets/js/player.js b/assets/js/player.js index 6ddb1158..07a5c128 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -42,7 +42,7 @@ embed_url = location.origin + '/embed/' + video_data.id + embed_url.search; var save_player_pos_key = 'save_player_pos'; videojs.Vhs.xhr.beforeRequest = function(options) { - if (options.uri.indexOf('videoplayback') === -1 && options.uri.indexOf('local=true') === -1) { + if (options.uri.includes('videoplayback') && options.uri.includes('local=true')) { options.uri = options.uri + '?local=true'; } return options; @@ -50,37 +50,38 @@ videojs.Vhs.xhr.beforeRequest = function(options) { var player = videojs('player', options); -player.on('error', () => { - if (video_data.params.quality !== 'dash') { - if (!player.currentSrc().includes("local=true") && !video_data.local_disabled) { - var currentSources = player.currentSources(); - for (var i = 0; i < currentSources.length; i++) { - currentSources[i]["src"] += "&local=true" - } - player.src(currentSources) - } - else if (player.error().code === 2 || player.error().code === 4) { - setTimeout(function (event) { - console.log('An error occurred in the player, reloading...'); - - var currentTime = player.currentTime(); - var playbackRate = player.playbackRate(); - var paused = player.paused(); - - player.load(); - - if (currentTime > 0.5) currentTime -= 0.5; +player.on('error', function () { + if (video_data.params.quality === 'dash') return; - player.currentTime(currentTime); - player.playbackRate(playbackRate); - - if (!paused) player.play(); - }, 10000); - } + var localNotDisabled = !player.currentSrc().includes('local=true') && !video_data.local_disabled; + var reloadMakesSense = player.error().code === MediaError.MEDIA_ERR_NETWORK || player.error().code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED; + + if (localNotDisabled) { + // add local=true to all current sources + player.src(player.currentSources().map(function (source) { + source.src += '&local=true'; + })); + } else if (reloadMakesSense) { + setTimeout(function (event) { + console.log('An error occurred in the player, reloading...'); + + var currentTime = player.currentTime(); + var playbackRate = player.playbackRate(); + var paused = player.paused(); + + player.load(); + + if (currentTime > 0.5) currentTime -= 0.5; + + player.currentTime(currentTime); + player.playbackRate(playbackRate); + + if (!paused) player.play(); + }, 10000); } }); -if (video_data.params.quality == 'dash') { +if (video_data.params.quality === 'dash') { player.reloadSourceOnError({ errorInterval: 10 }); @@ -89,7 +90,7 @@ if (video_data.params.quality == 'dash') { /** * Function for add time argument to url * @param {String} url - * @returns urlWithTimeArg + * @returns {URL} urlWithTimeArg */ function addCurrentTimeToURL(url) { var urlUsed = new URL(url); @@ -117,13 +118,6 @@ var shareOptions = { } }; -const storage = (function () { - try { if (localStorage.length !== -1) return localStorage; } - catch (e) { console.info('No storage available: ' + e); } - - return undefined; -})(); - if (location.pathname.startsWith('/embed/')) { var overlay_content = '

    ' + player_data.title + '

    '; player.overlay({ @@ -162,7 +156,7 @@ if (isMobile()) { buttons.forEach(function (child) {primary_control_bar.removeChild(child);}); var operations_bar_element = operations_bar.el(); - operations_bar_element.className += ' mobile-operations-bar'; + operations_bar_element.classList.add('mobile-operations-bar'); player.addChild(operations_bar); // Playback menu doesn't work when it's initialized outside of the primary control bar @@ -175,8 +169,8 @@ if (isMobile()) { operations_bar_element.append(share_element); if (video_data.params.quality === 'dash') { - var http_source_selector = document.getElementsByClassName('vjs-http-source-selector vjs-menu-button')[0]; - operations_bar_element.append(http_source_selector); + var http_source_selector = document.getElementsByClassName('vjs-http-source-selector vjs-menu-button')[0]; + operations_bar_element.append(http_source_selector); } }); } @@ -220,14 +214,14 @@ player.playbackRate(video_data.params.speed); * Method for getting the contents of a cookie * * @param {String} name Name of cookie - * @returns cookieValue + * @returns {String|null} cookieValue */ function getCookieValue(name) { - var value = document.cookie.split(';').filter(function (item) {return item.includes(name + '=');}); - - return (value.length >= 1) - ? value[0].substring((name + '=').length, value[0].length) - : null; + var cookiePrefix = name + '='; + var matchedCookie = document.cookie.split(';').find(function (item) {return item.includes(cookiePrefix);}); + if (matchedCookie) + return matchedCookie.replace(cookiePrefix, ''); + return null; } /** @@ -257,11 +251,11 @@ function updateCookie(newVolume, newSpeed) { date.setTime(date.getTime() + 63115200); var ipRegex = /^((\d+\.){3}\d+|[A-Fa-f0-9]*:[A-Fa-f0-9:]*:[A-Fa-f0-9:]+)$/; - var domainUsed = window.location.hostname; + var domainUsed = location.hostname; // Fix for a bug in FF where the leading dot in the FQDN is not ignored if (domainUsed.charAt(0) !== '.' && !ipRegex.test(domainUsed) && domainUsed !== 'localhost') - domainUsed = '.' + window.location.hostname; + domainUsed = '.' + location.hostname; document.cookie = 'PREFS=' + cookieData + '; SameSite=Strict; path=/; domain=' + domainUsed + '; expires=' + date.toGMTString() + ';'; @@ -280,7 +274,7 @@ player.on('volumechange', function () { player.on('waiting', function () { if (player.playbackRate() > 1 && player.liveTracker.isLive() && player.liveTracker.atLiveEdge()) { - console.info('Player has caught up to source, resetting playbackRate.'); + console.info('Player has caught up to source, resetting playbackRate'); player.playbackRate(1); } }); @@ -292,12 +286,12 @@ if (video_data.premiere_timestamp && Math.round(new Date() / 1000) < video_data. if (video_data.params.save_player_pos) { const url = new URL(location); const hasTimeParam = url.searchParams.has('t'); - const remeberedTime = get_video_time(); + const rememberedTime = get_video_time(); let lastUpdated = 0; - if(!hasTimeParam) set_seconds_after_start(remeberedTime); + if(!hasTimeParam) set_seconds_after_start(rememberedTime); - const updateTime = function () { + player.on('timeupdate', function () { const raw = player.currentTime(); const time = Math.floor(raw); @@ -305,9 +299,7 @@ if (video_data.params.save_player_pos) { save_video_time(time); lastUpdated = time; } - }; - - player.on('timeupdate', updateTime); + }); } else remove_all_video_times(); @@ -347,53 +339,31 @@ if (!video_data.params.listen && video_data.params.quality === 'dash') { targetQualityLevel = 0; break; default: - const targetHeight = Number.parseInt(video_data.params.quality_dash, 10); + const targetHeight = parseInt(video_data.params.quality_dash, 10); for (let i = 0; i < qualityLevels.length; i++) { - if (qualityLevels[i].height <= targetHeight) { + if (qualityLevels[i].height <= targetHeight) targetQualityLevel = i; - } else { + else break; - } } } - for (let i = 0; i < qualityLevels.length; i++) { - qualityLevels[i].enabled = (i === targetQualityLevel); - } + qualityLevels.forEach(function (level, index) { + level.enabled = (index === targetQualityLevel); + }); }); }); } } player.vttThumbnails({ - src: location.origin + '/api/v1/storyboards/' + video_data.id + '?height=90', + src: '/api/v1/storyboards/' + video_data.id + '?height=90', showTimestamp: true }); // Enable annotations if (!video_data.params.listen && video_data.params.annotations) { - 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) { - player.youtubeAnnotationsPlugin({ annotationXml: xhr.response, videoContainer: video_container }); - }); - } - } - } - }; - - window.addEventListener('__ar_annotation_click', function (e) { + addEventListener('load', function (e) { + addEventListener('__ar_annotation_click', function (e) { const url = e.detail.url, target = e.detail.target, seconds = e.detail.seconds; @@ -406,41 +376,48 @@ if (!video_data.params.listen && video_data.params.annotations) { path = path.pathname + path.search; if (target === 'current') { - window.location.href = path; + location.href = path; } else if (target === 'new') { - window.open(path, '_blank'); + open(path, '_blank'); + } + }); + + helpers.xhr('GET', '/api/v1/annotations/' + video_data.id, { + responseType: 'text', + timeout: 60000 + }, { + on200: function (response) { + var video_container = document.getElementById('player'); + videojs.registerPlugin('youtubeAnnotationsPlugin', youtubeAnnotationsPlugin); + if (player.paused()) { + player.one('play', function (event) { + player.youtubeAnnotationsPlugin({ annotationXml: response, videoContainer: video_container }); + }); + } else { + player.youtubeAnnotationsPlugin({ annotationXml: response, videoContainer: video_container }); + } } }); - xhr.send(); }); } function increase_volume(delta) { const curVolume = player.volume(); - let newVolume = curVolume + delta; - if (newVolume > 1) { - newVolume = 1; - } else if (newVolume < 0) { - newVolume = 0; - } + const newVolume = curVolume + delta; + helpers.clamp(newVolume, 0, 1); player.volume(newVolume); } function toggle_muted() { - const isMuted = player.muted(); - player.muted(!isMuted); + player.muted(!player.muted()); } function skip_seconds(delta) { const duration = player.duration(); const curTime = player.currentTime(); - let newTime = curTime + delta; - if (newTime > duration) { - newTime = duration; - } else if (newTime < 0) { - newTime = 0; - } + const newTime = curTime + delta; + helpers.clamp(newTime, 0, duration); player.currentTime(newTime); } @@ -455,52 +432,24 @@ function save_video_time(seconds) { all_video_times[videoId] = seconds; - set_all_video_times(all_video_times); + helpers.storage.set(save_player_pos_key, JSON.stringify(all_video_times)); } function get_video_time() { - try { - const videoId = video_data.id; - const all_video_times = get_all_video_times(); - const timestamp = all_video_times[videoId]; - - return timestamp || 0; - } - catch (e) { - return 0; - } -} + const videoId = video_data.id; + const all_video_times = get_all_video_times(); + const timestamp = all_video_times[videoId]; -function set_all_video_times(times) { - if (storage) { - if (times) { - try { - storage.setItem(save_player_pos_key, JSON.stringify(times)); - } catch (e) { - console.warn('set_all_video_times: ' + e); - } - } else { - storage.removeItem(save_player_pos_key); - } - } + return timestamp || 0; } function get_all_video_times() { - if (storage) { - const raw = storage.getItem(save_player_pos_key); - if (raw !== null) { - try { - return JSON.parse(raw); - } catch (e) { - console.warn('get_all_video_times: ' + e); - } - } - } - return {}; + const raw = helpers.storage.get(save_player_pos_key); + return raw ? JSON.parse(raw) : {}; } function remove_all_video_times() { - set_all_video_times(null); + helpers.storage.remove(save_player_pos_key); } function set_time_percent(percent) { @@ -516,21 +465,23 @@ function toggle_play() { player.paused() ? play() : pause(); } const toggle_captions = (function () { let toggledTrack = null; - const onChange = function (e) { - toggledTrack = null; - }; - const bindChange = function (onOrOff) { - player.textTracks()[onOrOff]('change', onChange); - }; + + function bindChange(onOrOff) { + player.textTracks()[onOrOff]('change', function (e) { + toggledTrack = null; + }); + } + // 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) { + function setMode(track, mode) { bindChange('off'); track.mode = mode; - window.setTimeout(function () { + setTimeout(function () { bindChange('on'); }, 0); - }; + } + bindChange('on'); return function () { if (toggledTrack !== null) { @@ -577,16 +528,12 @@ function toggle_fullscreen() { function increase_playback_rate(steps) { const maxIndex = options.playbackRates.length - 1; const curIndex = options.playbackRates.indexOf(player.playbackRate()); - let newIndex = curIndex + steps; - if (newIndex > maxIndex) { - newIndex = maxIndex; - } else if (newIndex < 0) { - newIndex = 0; - } + const newIndex = curIndex + steps; + helpers.clamp(newIndex, 0, maxIndex); player.playbackRate(options.playbackRates[newIndex]); } -window.addEventListener('keydown', function (e) { +addEventListener('keydown', function (e) { if (e.target.tagName.toLowerCase() === 'input') { // Ignore input when focus is on certain elements, e.g. form fields. return; @@ -673,12 +620,11 @@ window.addEventListener('keydown', function (e) { // 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; + // TODO: More precise step. Now FPS is taken equal to 29.97 + // Common FPS: https://forum.videohelp.com/threads/81868#post323588 + // Possible solution is new HTMLVideoElement.requestVideoFrameCallback() https://wicg.github.io/video-rvfc/ + case '.': action = function () { pause(); skip_seconds(-1/29.97); }; break; + case ',': action = function () { pause(); skip_seconds( 1/29.97); }; break; case '>': action = increase_playback_rate.bind(this, 1); break; case '<': action = increase_playback_rate.bind(this, -1); break; @@ -697,10 +643,6 @@ window.addEventListener('keydown', function (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 () { - const volumeStep = 0.05; - const enableVolumeScroll = true; - const enableHoverScroll = true; - const doc = document; const pEl = document.getElementById('player'); var volumeHover = false; @@ -710,39 +652,23 @@ window.addEventListener('keydown', function (e) { 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; - } - + function mouseScroll(event) { // 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); - } - } - } - } - }; + if (!player.controls() || !volumeHover) return; + + event.preventDefault(); + var wheelMove = event.wheelDelta || -event.detail; + var volumeSign = Math.sign(wheelMove); + + increase_volume(volumeSign * 0.05); // decrease/increase by 5% + } player.on('mousewheel', mouseScroll); player.on('DOMMouseScroll', mouseScroll); }()); // Since videojs-share can sometimes be blocked, we defer it until last -if (player.share) { - player.share(shareOptions); -} +if (player.share) player.share(shareOptions); // show the preferred caption by default if (player_data.preferred_caption_found) { @@ -763,7 +689,7 @@ if (navigator.vendor === 'Apple Computer, Inc.' && video_data.params.listen) { } // Watch on Invidious link -if (window.location.pathname.startsWith('/embed/')) { +if (location.pathname.startsWith('/embed/')) { const Button = videojs.getComponent('Button'); let watch_on_invidious_button = new Button(player); diff --git a/assets/js/playlist_widget.js b/assets/js/playlist_widget.js index c2565874..8f8da6d5 100644 --- a/assets/js/playlist_widget.js +++ b/assets/js/playlist_widget.js @@ -1,5 +1,6 @@ 'use strict'; var playlist_data = JSON.parse(document.getElementById('playlist_data').textContent); +var payload = 'csrf_token=' + playlist_data.csrf_token; function add_playlist_video(target) { var select = target.parentNode.children[0].children[1]; @@ -8,21 +9,12 @@ function add_playlist_video(target) { var url = '/playlist_ajax?action_add_video=1&redirect=false' + '&video_id=' + target.getAttribute('data-id') + '&playlist_id=' + option.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) { - option.innerText = '✓' + option.innerText; - } + helpers.xhr('POST', url, {payload: payload}, { + on200: function (response) { + option.innerText = '✓' + option.innerText; } - }; - - xhr.send('csrf_token=' + playlist_data.csrf_token); + }); } function add_playlist_item(target) { @@ -32,21 +24,12 @@ function add_playlist_item(target) { 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 = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + tile.style.display = ''; } - }; - - xhr.send('csrf_token=' + playlist_data.csrf_token); + }); } function remove_playlist_item(target) { @@ -56,19 +39,10 @@ function remove_playlist_item(target) { 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 = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + tile.style.display = ''; } - }; - - xhr.send('csrf_token=' + playlist_data.csrf_token); + }); } diff --git a/assets/js/subscribe_widget.js b/assets/js/subscribe_widget.js index 45ff5706..7665a00b 100644 --- a/assets/js/subscribe_widget.js +++ b/assets/js/subscribe_widget.js @@ -1,8 +1,9 @@ 'use strict'; var subscribe_data = JSON.parse(document.getElementById('subscribe_data').textContent); +var payload = 'csrf_token=' + subscribe_data.csrf_token; var subscribe_button = document.getElementById('subscribe'); -subscribe_button.parentNode['action'] = 'javascript:void(0)'; +subscribe_button.parentNode.action = 'javascript:void(0)'; if (subscribe_button.getAttribute('data-type') === 'subscribe') { subscribe_button.onclick = subscribe; @@ -10,87 +11,34 @@ if (subscribe_button.getAttribute('data-type') === 'subscribe') { subscribe_button.onclick = unsubscribe; } -function subscribe(retries) { - if (retries === undefined) retries = 5; - - if (retries <= 0) { - console.warn('Failed to subscribe.'); - return; - } - - var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' + - '&c=' + subscribe_data.ucid; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - +function subscribe() { var fallback = subscribe_button.innerHTML; subscribe_button.onclick = unsubscribe; subscribe_button.innerHTML = '' + subscribe_data.unsubscribe_text + ' | ' + subscribe_data.sub_count_text + ''; - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - subscribe_button.onclick = subscribe; - subscribe_button.innerHTML = fallback; - } - } - }; - - xhr.onerror = function () { - console.warn('Subscribing failed... ' + retries + '/5'); - setTimeout(function () { subscribe(retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - console.warn('Subscribing failed... ' + retries + '/5'); - subscribe(retries - 1); - }; + var url = '/subscription_ajax?action_create_subscription_to_channel=1&redirect=false' + + '&c=' + subscribe_data.ucid; - xhr.send('csrf_token=' + subscribe_data.csrf_token); + helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'subscribe request'}, { + onNon200: function (xhr) { + subscribe_button.onclick = subscribe; + subscribe_button.innerHTML = fallback; + } + }); } -function unsubscribe(retries) { - if (retries === undefined) - retries = 5; - - if (retries <= 0) { - console.warn('Failed to subscribe'); - return; - } - - var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' + - '&c=' + subscribe_data.ucid; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('POST', url, true); - xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); - +function unsubscribe() { var fallback = subscribe_button.innerHTML; subscribe_button.onclick = subscribe; subscribe_button.innerHTML = '' + subscribe_data.subscribe_text + ' | ' + subscribe_data.sub_count_text + ''; - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status !== 200) { - subscribe_button.onclick = unsubscribe; - subscribe_button.innerHTML = fallback; - } - } - }; - - xhr.onerror = function () { - console.warn('Unsubscribing failed... ' + retries + '/5'); - setTimeout(function () { unsubscribe(retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - console.warn('Unsubscribing failed... ' + retries + '/5'); - unsubscribe(retries - 1); - }; + var url = '/subscription_ajax?action_remove_subscriptions=1&redirect=false' + + '&c=' + subscribe_data.ucid; - xhr.send('csrf_token=' + subscribe_data.csrf_token); + helpers.xhr('POST', url, {payload: payload, retries: 5, entity_name: 'unsubscribe request'}, { + onNon200: function (xhr) { + subscribe_button.onclick = unsubscribe; + subscribe_button.innerHTML = fallback; + } + }); } diff --git a/assets/js/themes.js b/assets/js/themes.js index 3f503b38..7e86e9ac 100644 --- a/assets/js/themes.js +++ b/assets/js/themes.js @@ -1,60 +1,48 @@ 'use strict'; var toggle_theme = document.getElementById('toggle_theme'); -toggle_theme.href = 'javascript:void(0);'; +toggle_theme.href = 'javascript:void(0)'; toggle_theme.addEventListener('click', function () { var dark_mode = document.body.classList.contains('light-theme'); - var url = '/toggle_theme?redirect=false'; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - set_mode(dark_mode); - try { - window.localStorage.setItem('dark_mode', dark_mode ? 'dark' : 'light'); - } catch (e) {} + helpers.storage.set('dark_mode', dark_mode ? 'dark' : 'light'); - xhr.send(); + helpers.xhr('GET', '/toggle_theme?redirect=false', {}, {}); }); -window.addEventListener('storage', function (e) { +// Handles theme change event caused by other tab +addEventListener('storage', function (e) { if (e.key === 'dark_mode') { update_mode(e.newValue); } }); -window.addEventListener('DOMContentLoaded', function () { +addEventListener('DOMContentLoaded', function () { const dark_mode = document.getElementById('dark_mode_pref').textContent; - try { - // Update localStorage if dark mode preference changed on preferences page - window.localStorage.setItem('dark_mode', dark_mode); - } catch (e) {} + // Update storage if dark mode preference changed on preferences page + helpers.storage.set('dark_mode', dark_mode); update_mode(dark_mode); }); -var darkScheme = window.matchMedia('(prefers-color-scheme: dark)'); -var lightScheme = window.matchMedia('(prefers-color-scheme: light)'); +var darkScheme = matchMedia('(prefers-color-scheme: dark)'); +var lightScheme = matchMedia('(prefers-color-scheme: light)'); darkScheme.addListener(scheme_switch); lightScheme.addListener(scheme_switch); function scheme_switch (e) { - // ignore this method if we have a preference set - try { - if (localStorage.getItem('dark_mode')) { - return; - } - } catch (exception) {} - if (e.matches) { + // ignore this method if we have a preference set + if (helpers.storage.get('dark_mode')) return; + + if (!e.matches) return; + if (e.media.includes('dark')) { - set_mode(true); + set_mode(true); } else if (e.media.includes('light')) { - set_mode(false); + set_mode(false); } - } } function set_mode (bool) { @@ -82,7 +70,7 @@ function update_mode (mode) { // If preference for light mode indicated set_mode(false); } - else if (document.getElementById('dark_mode_pref').textContent === '' && window.matchMedia('(prefers-color-scheme: dark)').matches) { + else if (document.getElementById('dark_mode_pref').textContent === '' && matchMedia('(prefers-color-scheme: dark)').matches) { // If no preference indicated here and no preference indicated on the preferences page (backend), but the browser tells us that the operating system has a dark theme set_mode(true); } diff --git a/assets/js/watch.js b/assets/js/watch.js index 29d58be5..ff0f7822 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -1,5 +1,7 @@ 'use strict'; var video_data = JSON.parse(document.getElementById('video_data').textContent); +var spinnerHTML = '

    '; +var spinnerHTMLwithHR = spinnerHTML + '
    '; String.prototype.supplant = function (o) { return this.replace(/{([^{}]*)}/g, function (a, b) { @@ -10,24 +12,24 @@ String.prototype.supplant = function (o) { function toggle_parent(target) { var body = target.parentNode.parentNode.children[1]; - if (body.style.display === null || body.style.display === '') { - target.textContent = '[ + ]'; - body.style.display = 'none'; - } else { + if (body.style.display === 'none') { target.textContent = '[ − ]'; body.style.display = ''; + } else { + target.textContent = '[ + ]'; + body.style.display = 'none'; } } function toggle_comments(event) { var target = event.target; var body = target.parentNode.parentNode.parentNode.children[1]; - if (body.style.display === null || body.style.display === '') { - target.textContent = '[ + ]'; - body.style.display = 'none'; - } else { + if (body.style.display === 'none') { target.textContent = '[ − ]'; body.style.display = ''; + } else { + target.textContent = '[ + ]'; + body.style.display = 'none'; } } @@ -79,31 +81,22 @@ if (continue_button) { function next_video() { var url = new URL('https://example.com/watch?v=' + video_data.next_video); - if (video_data.params.autoplay || video_data.params.continue_autoplay) { + if (video_data.params.autoplay || video_data.params.continue_autoplay) url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.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) { + 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) { + if (video_data.params.local !== video_data.preferences.local) url.searchParams.set('local', video_data.params.local); - } - url.searchParams.set('continue', '1'); + location.assign(url.pathname + url.search); } function continue_autoplay(event) { if (event.target.checked) { - player.on('ended', function () { - next_video(); - }); + player.on('ended', next_video); } else { player.off('ended'); } @@ -116,19 +109,10 @@ function number_with_separator(val) { return val; } -function get_playlist(plid, retries) { - if (retries === undefined) retries = 5; +function get_playlist(plid) { var playlist = document.getElementById('playlist'); - if (retries <= 0) { - console.warn('Failed to pull playlist'); - playlist.innerHTML = ''; - return; - } - - playlist.innerHTML = ' \ -

    \ -
    '; + playlist.innerHTML = spinnerHTMLwithHR; var plid_url; if (plid.startsWith('RD')) { @@ -142,225 +126,144 @@ function get_playlist(plid, retries) { '&format=html&hl=' + video_data.preferences.locale; } - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', plid_url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - playlist.innerHTML = xhr.response.playlistHtml; - var nextVideo = document.getElementById(xhr.response.nextVideo); - nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop; - - if (xhr.response.nextVideo) { - player.on('ended', function () { - var url = new URL('https://example.com/watch?v=' + xhr.response.nextVideo); - - url.searchParams.set('list', plid); - if (!plid.startsWith('RD')) { - url.searchParams.set('index', xhr.response.index); - } - - 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.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); - } - - location.assign(url.pathname + url.search); - }); - } - } else { - playlist.innerHTML = ''; - document.getElementById('continue').style.display = ''; - } + helpers.xhr('GET', plid_url, {retries: 5, entity_name: 'playlist'}, { + on200: function (response) { + playlist.innerHTML = response.playlistHtml; + + if (!response.nextVideo) return; + + var nextVideo = document.getElementById(response.nextVideo); + nextVideo.parentNode.parentNode.scrollTop = nextVideo.offsetTop; + + player.on('ended', function () { + var url = new URL('https://example.com/watch?v=' + response.nextVideo); + + url.searchParams.set('list', plid); + if (!plid.startsWith('RD')) + url.searchParams.set('index', response.index); + 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.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); + + location.assign(url.pathname + url.search); + }); + }, + onNon200: function (xhr) { + playlist.innerHTML = ''; + document.getElementById('continue').style.display = ''; + }, + onError: function (xhr) { + playlist.innerHTML = spinnerHTMLwithHR; + }, + onTimeout: function (xhr) { + playlist.innerHTML = spinnerHTMLwithHR; } - }; - - xhr.onerror = function () { - playlist = document.getElementById('playlist'); - playlist.innerHTML = - '


    '; - - console.warn('Pulling playlist timed out... ' + retries + '/5'); - setTimeout(function () { get_playlist(plid, retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - playlist = document.getElementById('playlist'); - playlist.innerHTML = - '


    '; - - console.warn('Pulling playlist timed out... ' + retries + '/5'); - get_playlist(plid, retries - 1); - }; - - xhr.send(); + }); } -function get_reddit_comments(retries) { - if (retries === undefined) retries = 5; +function get_reddit_comments() { var comments = document.getElementById('comments'); - if (retries <= 0) { - console.warn('Failed to pull comments'); - comments.innerHTML = ''; - return; - } - var fallback = comments.innerHTML; - comments.innerHTML = - '

    '; + comments.innerHTML = spinnerHTML; var url = '/api/v1/comments/' + video_data.id + '?source=reddit&format=html' + '&hl=' + video_data.preferences.locale; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - comments.innerHTML = ' \ -
    \ -

    \ - [ − ] \ - {title} \ -

    \ -

    \ - \ - \ - {youtubeCommentsText} \ - \ - \ -

    \ + + var onNon200 = function (xhr) { comments.innerHTML = fallback; }; + if (video_data.params.comments[1] === 'youtube') + onNon200 = function (xhr) {}; + + helpers.xhr('GET', url, {retries: 5, entity_name: ''}, { + on200: function (response) { + comments.innerHTML = ' \ +
    \ +

    \ + [ − ] \ + {title} \ +

    \ +

    \ \ - {redditPermalinkText} \ + \ + {youtubeCommentsText} \ + \ \ -

    \ -
    {contentHtml}
    \ -
    '.supplant({ - title: xhr.response.title, - youtubeCommentsText: video_data.youtube_comments_text, - redditPermalinkText: video_data.reddit_permalink_text, - permalink: xhr.response.permalink, - contentHtml: xhr.response.contentHtml - }); - - comments.children[0].children[0].children[0].onclick = toggle_comments; - comments.children[0].children[1].children[0].onclick = swap_comments; - } else { - if (video_data.params.comments[1] === 'youtube') { - console.warn('Pulling comments failed... ' + retries + '/5'); - setTimeout(function () { get_youtube_comments(retries - 1); }, 1000); - } else { - comments.innerHTML = fallback; - } - } - } - }; - - xhr.onerror = function () { - console.warn('Pulling comments failed... ' + retries + '/5'); - setTimeout(function () { get_reddit_comments(retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - console.warn('Pulling comments failed... ' + retries + '/5'); - get_reddit_comments(retries - 1); - }; - - xhr.send(); +

    \ + \ + {redditPermalinkText} \ + \ +
    \ +
    {contentHtml}
    \ +
    '.supplant({ + title: response.title, + youtubeCommentsText: video_data.youtube_comments_text, + redditPermalinkText: video_data.reddit_permalink_text, + permalink: response.permalink, + contentHtml: response.contentHtml + }); + + comments.children[0].children[0].children[0].onclick = toggle_comments; + comments.children[0].children[1].children[0].onclick = swap_comments; + }, + onNon200: onNon200, // declared above + }); } -function get_youtube_comments(retries) { - if (retries === undefined) retries = 5; +function get_youtube_comments() { var comments = document.getElementById('comments'); - if (retries <= 0) { - console.warn('Failed to pull comments'); - comments.innerHTML = ''; - return; - } - var fallback = comments.innerHTML; - comments.innerHTML = - '

    '; + comments.innerHTML = spinnerHTML; var url = '/api/v1/comments/' + video_data.id + '?format=html' + '&hl=' + video_data.preferences.locale + '&thin_mode=' + video_data.preferences.thin_mode; - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - comments.innerHTML = ' \ -
    \ -

    \ - [ − ] \ - {commentsText} \ -

    \ - \ - \ - {redditComments} \ - \ - \ -
    \ -
    {contentHtml}
    \ -
    '.supplant({ - contentHtml: xhr.response.contentHtml, - redditComments: video_data.reddit_comments_text, - commentsText: video_data.comments_text.supplant( - { commentCount: number_with_separator(xhr.response.commentCount) } - ) - }); - - comments.children[0].children[0].children[0].onclick = toggle_comments; - comments.children[0].children[1].children[0].onclick = swap_comments; - } else { - if (video_data.params.comments[1] === 'youtube') { - setTimeout(function () { get_youtube_comments(retries - 1); }, 1000); - } else { - comments.innerHTML = ''; - } - } + + var onNon200 = function (xhr) { comments.innerHTML = fallback; }; + if (video_data.params.comments[1] === 'youtube') + onNon200 = function (xhr) {}; + + helpers.xhr('GET', url, {retries: 5, entity_name: 'comments'}, { + on200: function (response) { + comments.innerHTML = ' \ +
    \ +

    \ + [ − ] \ + {commentsText} \ +

    \ + \ + \ + {redditComments} \ + \ + \ +
    \ +
    {contentHtml}
    \ +
    '.supplant({ + contentHtml: response.contentHtml, + redditComments: video_data.reddit_comments_text, + commentsText: video_data.comments_text.supplant( + { commentCount: number_with_separator(response.commentCount) } + ) + }); + + comments.children[0].children[0].children[0].onclick = toggle_comments; + comments.children[0].children[1].children[0].onclick = swap_comments; + }, + onNon200: onNon200, // declared above + onError: function (xhr) { + comments.innerHTML = spinnerHTML; + }, + onTimeout: function (xhr) { + comments.innerHTML = spinnerHTML; } - }; - - xhr.onerror = function () { - comments.innerHTML = - '

    '; - console.warn('Pulling comments failed... ' + retries + '/5'); - setTimeout(function () { get_youtube_comments(retries - 1); }, 1000); - }; - - xhr.ontimeout = function () { - comments.innerHTML = - '

    '; - console.warn('Pulling comments failed... ' + retries + '/5'); - get_youtube_comments(retries - 1); - }; - - xhr.send(); + }); } function get_youtube_replies(target, load_more, load_replies) { @@ -368,91 +271,72 @@ function get_youtube_replies(target, load_more, load_replies) { var body = target.parentNode.parentNode; var fallback = body.innerHTML; - body.innerHTML = - '

    '; + body.innerHTML = spinnerHTML; var url = '/api/v1/comments/' + video_data.id + '?format=html' + '&hl=' + video_data.preferences.locale + '&thin_mode=' + video_data.preferences.thin_mode + '&continuation=' + continuation; - if (load_replies) { - url += '&action=action_get_comment_replies'; - } - var xhr = new XMLHttpRequest(); - xhr.responseType = 'json'; - xhr.timeout = 10000; - xhr.open('GET', url, true); - - xhr.onreadystatechange = function () { - if (xhr.readyState === 4) { - if (xhr.status === 200) { - if (load_more) { - body = body.parentNode.parentNode; - body.removeChild(body.lastElementChild); - body.innerHTML += xhr.response.contentHtml; - } else { - body.removeChild(body.lastElementChild); - - var p = document.createElement('p'); - var a = document.createElement('a'); - p.appendChild(a); - - a.href = 'javascript:void(0)'; - a.onclick = hide_youtube_replies; - a.setAttribute('data-sub-text', video_data.hide_replies_text); - a.setAttribute('data-inner-text', video_data.show_replies_text); - a.innerText = video_data.hide_replies_text; - - var div = document.createElement('div'); - div.innerHTML = xhr.response.contentHtml; - - body.appendChild(p); - body.appendChild(div); - } + if (load_replies) url += '&action=action_get_comment_replies'; + + helpers.xhr('GET', url, {}, { + on200: function (response) { + if (load_more) { + body = body.parentNode.parentNode; + body.removeChild(body.lastElementChild); + body.innerHTML += response.contentHtml; } else { - body.innerHTML = fallback; - } - } - }; + body.removeChild(body.lastElementChild); + + var p = document.createElement('p'); + var a = document.createElement('a'); + p.appendChild(a); - xhr.ontimeout = function () { - console.warn('Pulling comments failed.'); - body.innerHTML = fallback; - }; + a.href = 'javascript:void(0)'; + a.onclick = hide_youtube_replies; + a.setAttribute('data-sub-text', video_data.hide_replies_text); + a.setAttribute('data-inner-text', video_data.show_replies_text); + a.innerText = video_data.hide_replies_text; - xhr.send(); + var div = document.createElement('div'); + div.innerHTML = response.contentHtml; + + body.appendChild(p); + body.appendChild(div); + } + }, + onNon200: function (xhr) { + body.innerHTML = fallback; + }, + onTimeout: function (xhr) { + console.warn('Pulling comments failed'); + body.innerHTML = fallback; + } + }); } if (video_data.play_next) { player.on('ended', function () { var url = new URL('https://example.com/watch?v=' + video_data.next_video); - if (video_data.params.autoplay || video_data.params.continue_autoplay) { + if (video_data.params.autoplay || video_data.params.continue_autoplay) url.searchParams.set('autoplay', '1'); - } - - if (video_data.params.listen !== video_data.preferences.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) { + 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) { + if (video_data.params.local !== video_data.preferences.local) url.searchParams.set('local', video_data.params.local); - } - url.searchParams.set('continue', '1'); + location.assign(url.pathname + url.search); }); } -window.addEventListener('load', function (e) { - if (video_data.plid) { +addEventListener('load', function (e) { + if (video_data.plid) get_playlist(video_data.plid); - } if (video_data.params.comments[0] === 'youtube') { get_youtube_comments(); diff --git a/assets/js/watched_widget.js b/assets/js/watched_widget.js index 87989a79..497b1878 100644 --- a/assets/js/watched_widget.js +++ b/assets/js/watched_widget.js @@ -1,5 +1,6 @@ 'use strict'; var watched_data = JSON.parse(document.getElementById('watched_data').textContent); +var payload = 'csrf_token=' + watched_data.csrf_token; function mark_watched(target) { var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; @@ -7,45 +8,27 @@ function mark_watched(target) { var url = '/watch_ajax?action_mark_watched=1&redirect=false' + '&id=' + target.getAttribute('data-id'); - 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 = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + tile.style.display = ''; } - }; - - xhr.send('csrf_token=' + watched_data.csrf_token); + }); } function mark_unwatched(target) { var tile = target.parentNode.parentNode.parentNode.parentNode.parentNode; tile.style.display = 'none'; var count = document.getElementById('count'); - count.innerText = count.innerText - 1; + count.innerText = parseInt(count.innerText) - 1; var url = '/watch_ajax?action_mark_unwatched=1&redirect=false' + '&id=' + target.getAttribute('data-id'); - 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) { - count.innerText = count.innerText - 1 + 2; - tile.style.display = ''; - } + helpers.xhr('POST', url, {payload: payload}, { + onNon200: function (xhr) { + count.innerText = parseInt(count.innerText) + 1; + tile.style.display = ''; } - }; - - xhr.send('csrf_token=' + watched_data.csrf_token); + }); } diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 1f8de657..f50b5907 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -481,7 +481,7 @@ def template_reddit_comments(root, locale) html << <<-END_HTML

    - [ - ] + [ − ] #{child.author} #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)} #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))} diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index 22870317..758f3995 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -29,6 +29,7 @@ }.to_pretty_json %> +

    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 3bc29e55..154c40b5 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -93,4 +93,5 @@ }.to_pretty_json %> + diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index fffefc9a..483807d7 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -66,4 +66,5 @@ }.to_pretty_json %> + diff --git a/src/invidious/views/components/subscribe_widget.ecr b/src/invidious/views/components/subscribe_widget.ecr index b9d5f783..7a8c7fda 100644 --- a/src/invidious/views/components/subscribe_widget.ecr +++ b/src/invidious/views/components/subscribe_widget.ecr @@ -31,6 +31,7 @@ }.to_pretty_json %> + <% else %>

    diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index ce5ff7f0..82f80f9d 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -31,6 +31,7 @@ <%= rendered "components/player" %> + diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr index 6c1243c5..51dd78bd 100644 --- a/src/invidious/views/feeds/history.ecr +++ b/src/invidious/views/feeds/history.ecr @@ -25,6 +25,7 @@ }.to_pretty_json %> +

    diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr index 8d56ad14..957277fa 100644 --- a/src/invidious/views/feeds/subscriptions.ecr +++ b/src/invidious/views/feeds/subscriptions.ecr @@ -50,6 +50,7 @@ }.to_pretty_json %> +
    diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 861913d0..25b24ed4 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -9,6 +9,20 @@

    <%= translate(locale, "JavaScript license information") %>

    + + + + + + + + + + + + + + + + -- cgit v1.2.3 From 9e58bc19c4baf7ca7da97c2f8b164789d041d9b8 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 13 Aug 2022 20:23:45 +0200 Subject: Fix #3265 --- src/invidious/routing.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index b1cef086..f409f13c 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -4,7 +4,7 @@ module Invidious::Routing {% for http_method in {"get", "post", "delete", "options", "patch", "put"} %} macro {{http_method.id}}(path, controller, method = :handle) - unless !Kemal::Utils.path_starts_with_slash?(\{{path}}) + unless Kemal::Utils.path_starts_with_slash?(\{{path}}) raise Kemal::Exceptions::InvalidPathStartException.new({{http_method}}, \{{path}}) end -- cgit v1.2.3 From b2c0f7efc373e924e401c030849c20566e813d0a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 15 Aug 2022 10:34:52 +0200 Subject: Fix missing hash key: "toggleButtonRenderer" (issue #3260) --- src/invidious/videos.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index f87c6b47..cb1d1acf 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1012,7 +1012,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any if toplevel_buttons likes_button = toplevel_buttons.as_a - .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "LIKE") + .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE") .try &.["toggleButtonRenderer"] if likes_button -- cgit v1.2.3 From d950a0ef5d552ec42547c51feefe1e24811438ee Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 15 Aug 2022 12:31:33 +0200 Subject: StaticFileHandler: Adapt for Crystal 1.6 See: - https://github.com/crystal-lang/crystal/pull/12310 - https://github.com/kemalcr/kemal/pull/644 --- src/ext/kemal_static_file_handler.cr | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/ext/kemal_static_file_handler.cr b/src/ext/kemal_static_file_handler.cr index 6ef2d74c..64b16600 100644 --- a/src/ext/kemal_static_file_handler.cr +++ b/src/ext/kemal_static_file_handler.cr @@ -149,7 +149,9 @@ module Kemal send_file(context, file_path, file[:data], file[:filestat]) else - is_dir = Dir.exists? file_path + file_info = File.info?(file_path) + is_dir = file_info.try &.directory? || false + is_file = file_info.try &.file? || false if request_path != expanded_path redirect_to context, expanded_path @@ -157,15 +159,17 @@ module Kemal redirect_to context, expanded_path + '/' end - if Dir.exists?(file_path) + return call_next(context) if file_info.nil? + + if is_dir if config.is_a?(Hash) && config["dir_listing"] == true context.response.content_type = "text/html" directory_listing(context.response, request_path, file_path) else call_next(context) end - elsif File.exists?(file_path) - last_modified = modification_time(file_path) + elsif is_file + last_modified = file_info.modification_time add_cache_headers(context.response.headers, last_modified) if cache_request?(context, last_modified) @@ -177,14 +181,12 @@ module Kemal data = Bytes.new(size) File.open(file_path, &.read(data)) - filestat = File.info(file_path) - - @cached_files[file_path] = {data: data, filestat: filestat} - send_file(context, file_path, data, filestat) + @cached_files[file_path] = {data: data, filestat: file_info} + send_file(context, file_path, data, file_info) else send_file(context, file_path) end - else + else # Not a normal file (FIFO/device/socket) call_next(context) end end -- cgit v1.2.3 From 5565204273de4140d7b72ab201adec1dd90ecf0c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 15 Aug 2022 13:22:07 +0200 Subject: StaticFileHandler: use HTTP::Status rather than integers --- src/ext/kemal_static_file_handler.cr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/ext/kemal_static_file_handler.cr b/src/ext/kemal_static_file_handler.cr index 64b16600..eb068aeb 100644 --- a/src/ext/kemal_static_file_handler.cr +++ b/src/ext/kemal_static_file_handler.cr @@ -111,7 +111,7 @@ module Kemal if @fallthrough call_next(context) else - context.response.status_code = 405 + context.response.status = HTTP::Status::METHOD_NOT_ALLOWED context.response.headers.add("Allow", "GET, HEAD") end return @@ -124,7 +124,7 @@ module Kemal # File path cannot contains '\0' (NUL) because all filesystem I know # don't accept '\0' character as file name. if request_path.includes? '\0' - context.response.status_code = 400 + context.response.status = HTTP::Status::BAD_REQUEST return end @@ -143,7 +143,7 @@ module Kemal add_cache_headers(context.response.headers, last_modified) if cache_request?(context, last_modified) - context.response.status_code = 304 + context.response.status = HTTP::Status::NOT_MODIFIED return end @@ -173,7 +173,7 @@ module Kemal add_cache_headers(context.response.headers, last_modified) if cache_request?(context, last_modified) - context.response.status_code = 304 + context.response.status = HTTP::Status::NOT_MODIFIED return end -- cgit v1.2.3 From ca4c2115eebd5b3eaeb7ebf9e4e704ad83e5b4bf Mon Sep 17 00:00:00 2001 From: Emilien Devos Date: Sat, 6 Aug 2022 13:14:35 +0200 Subject: Message when the video doesn't exist in playlist --- locales/en-US.json | 3 ++- src/invidious/routes/embed.cr | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 9701a621..5caa4bd1 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -471,5 +471,6 @@ "crash_page_switch_instance": "tried to use another instance", "crash_page_read_the_faq": "read the Frequently Asked Questions (FAQ)", "crash_page_search_issue": "searched for existing issues on GitHub", - "crash_page_report_issue": "If none of the above helped, please open a new issue on GitHub (preferably in English) and include the following text in your message (do NOT translate that text):" + "crash_page_report_issue": "If none of the above helped, please open a new issue on GitHub (preferably in English) and include the following text in your message (do NOT translate that text):", + "video_not_exist_in_playlist": "The video requested doesn't exist in the playlist. Click here for the playlist home page." } diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 84da9993..7860f8b9 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -2,11 +2,16 @@ module Invidious::Routes::Embed def self.redirect(env) + locale = env.get("preferences").as(Preferences).locale if plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") begin playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 videos = get_playlist_videos(playlist, offset: offset) + if videos.empty? + url = "/playlist?list=#{plid}" + raise NotFoundException.new(translate(locale, "video_not_exist_in_playlist", url)) + end rescue ex : NotFoundException return error_template(404, ex) rescue ex @@ -26,6 +31,7 @@ module Invidious::Routes::Embed end def self.show(env) + locale = env.get("preferences").as(Preferences).locale id = env.params.url["id"] plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") @@ -62,6 +68,10 @@ module Invidious::Routes::Embed playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 videos = get_playlist_videos(playlist, offset: offset) + if videos.empty? + url = "/playlist?list=#{plid}" + raise NotFoundException.new(translate(locale, "video_not_exist_in_playlist", url)) + end rescue ex : NotFoundException return error_template(404, ex) rescue ex -- cgit v1.2.3 From 389e49183c076362bb79ad377b227257488e2bce Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Tue, 23 Aug 2022 09:50:57 +0000 Subject: throw error if the videoID returned is different --- src/invidious/exceptions.cr | 3 +++ src/invidious/videos.cr | 4 ++++ 2 files changed, 7 insertions(+) (limited to 'src') diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index 05be73a6..425c08da 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -30,3 +30,6 @@ end # Exception threw when an element is not found. class NotFoundException < InfoException end + +class VideoNotAvailableException < Exception +end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index cb1d1acf..5ed57727 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -909,6 +909,10 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ "reason" => JSON::Any.new(reason), } end + elsif video_id != player_response.dig("videoDetails", "videoId") + # YouTube may return a different video player response than expected. + # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 + raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one.") else reason = nil end -- cgit v1.2.3 From a7d9df551675169014a3a9481f9a3871f055d9db Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Thu, 25 Aug 2022 10:39:10 +0200 Subject: add check video id for android client too (#3280) --- src/invidious/videos.cr | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 5ed57727..c0ed6e85 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -912,7 +912,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ elsif video_id != player_response.dig("videoDetails", "videoId") # YouTube may return a different video player response than expected. # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 - raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one.") + raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)") else reason = nil end @@ -937,10 +937,14 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) - # Sometime, the video is available from the web client, but not on Android, so check + # Sometimes, the video is available from the web client, but not on Android, so check # that here, and fallback to the streaming data from the web client if needed. # See: https://github.com/iv-org/invidious/issues/2549 - if android_player["playabilityStatus"]["status"] == "OK" + if video_id != android_player.dig("videoDetails", "videoId") + # YouTube may return a different video player response than expected. + # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 + raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (ANDROID client)") + elsif android_player["playabilityStatus"]["status"] == "OK" params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("") else params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("") -- cgit v1.2.3 From 4818b89ab164713b1e52b7b03e48d47b6e7bce26 Mon Sep 17 00:00:00 2001 From: Jakub Filo Date: Sat, 27 Aug 2022 22:36:07 +0200 Subject: Allow to set maximum custom playlist length via a config variable. --- config/config.example.yml | 8 ++++++++ src/invidious/config.cr | 3 +++ src/invidious/database/playlists.cr | 2 +- src/invidious/routes/api/v1/authenticated.cr | 4 ++-- src/invidious/routes/playlists.cr | 6 +++--- src/invidious/routes/subscriptions.cr | 2 +- src/invidious/user/imports.cr | 2 +- 7 files changed, 19 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 10734c3a..424e2a38 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -869,3 +869,11 @@ default_user_preferences: ## Default: false ## #extend_desc: false + + ## + ## Maximum custom playlist length limit. + ## + ## Accepted values: Integer + ## Default: 500 + ## + #playlist_length_limit: 500 diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 786b65df..f0873df4 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -131,6 +131,9 @@ class Config # API URL for Anti-Captcha property captcha_api_url : String = "https://api.anti-captcha.com" + # Playlist length limit + property playlist_length_limit : Int32 = 500 + def disabled?(option) case disabled = CONFIG.disable_proxy when Bool diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index c6754a1e..5f47ff95 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -248,7 +248,7 @@ module Invidious::Database::PlaylistVideos return PG_DB.query_one?(request, plid, index, as: String) end - def select_ids(plid : String, index : VideoIndex, limit = 500) : Array(String) + def select_ids(plid : String, index : VideoIndex, limit = CONFIG.playlist_length_limit) : Array(String) request = <<-SQL SELECT id FROM playlist_videos WHERE plid = $1 diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 1f5ad8ef..421355bb 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -226,8 +226,8 @@ module Invidious::Routes::API::V1::Authenticated return error_json(403, "Invalid user") end - if playlist.index.size >= 500 - return error_json(400, "Playlist cannot have more than 500 videos") + if playlist.index.size >= CONFIG.playlist_length_limit + return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") end video_id = env.params.json["videoId"].try &.as(String) diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index fe7e4e1c..0d242ee6 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -330,11 +330,11 @@ module Invidious::Routes::Playlists when "action_edit_playlist" # TODO: Playlist stub when "action_add_video" - if playlist.index.size >= 500 + if playlist.index.size >= CONFIG.playlist_length_limit if redirect - return error_template(400, "Playlist cannot have more than 500 videos") + return error_template(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") else - return error_json(400, "Playlist cannot have more than 500 videos") + return error_json(400, "Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") end end diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr index 7b1fa876..ed595d9a 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -120,7 +120,7 @@ module Invidious::Routes::Subscriptions json.field "privacy", playlist.privacy.to_s json.field "videos" do json.array do - Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| + Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: CONFIG.playlist_length_limit).each do |video_id| json.string video_id end end diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index f8b9e4e4..bd929e4d 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -71,7 +71,7 @@ struct Invidious::User Invidious::Database::Playlists.update_description(playlist.id, description) videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| - raise InfoException.new("Playlist cannot have more than 500 videos") if idx > 500 + raise InfoException.new("Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") if idx > 500 video_id = video_id.try &.as_s? next if !video_id -- cgit v1.2.3 From 508a5761a1ce154d6d51c51a647403ea480ae46a Mon Sep 17 00:00:00 2001 From: Andrei E Date: Sun, 28 Aug 2022 13:26:30 +0100 Subject: Handle long usernames gracefully --- src/invidious/views/template.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index caf5299f..98f72eba 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -67,7 +67,7 @@ <% if env.get("preferences").as(Preferences).show_nick %> -
    +
    <%= HTML.escape(env.get("user").as(Invidious::User).email) %>
    <% end %> -- cgit v1.2.3 From 31244cbcc89fa816e38afad1b4962fbe46497326 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Tue, 30 Aug 2022 14:20:08 +0000 Subject: replicate headers and params made by yt apps --- src/invidious/yt_backend/connection_pool.cr | 14 ++-- src/invidious/yt_backend/youtube_api.cr | 110 ++++++++++++++++++++++------ 2 files changed, 96 insertions(+), 28 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 3feb9233..23e98ae3 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -7,17 +7,19 @@ {% end %} def add_yt_headers(request) - request.headers["user-agent"] ||= "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/78.0.3904.97 Safari/537.36" - request.headers["accept-charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" - request.headers["accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" - request.headers["accept-language"] ||= "en-us,en;q=0.5" + if request.headers["User-Agent"] == "Crystal" + request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36" + end + request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" + request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + request.headers["Accept-Language"] ||= "en-us,en;q=0.5" return if request.resource.starts_with? "/sorry/index" request.headers["x-youtube-client-name"] ||= "1" request.headers["x-youtube-client-version"] ||= "2.20200609" # Preserve original cookies and add new YT consent cookie for EU servers - request.headers["cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" + request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" if !CONFIG.cookies.empty? - request.headers["cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" + request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" end end diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 30d7613b..c014dc0e 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -7,9 +7,12 @@ module YoutubeAPI private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" - private ANDROID_APP_VERSION = "17.29.35" - private ANDROID_SDK_VERSION = 30_i64 - private IOS_APP_VERSION = "17.30.1" + private ANDROID_APP_VERSION = "17.33.42" + private ANDROID_USER_AGENT = "com.google.android.youtube/17.33.42 (Linux; U; Android 12; US)" + private ANDROID_SDK_VERSION = 31_i64 + private ANDROID_VERSION = "12" + private IOS_APP_VERSION = "17.33.2" + private WINDOWS_VERSION = "10.0" # Enumerate used to select one of the clients supported by the API enum ClientType @@ -33,27 +36,39 @@ module YoutubeAPI # List of hard-coded values used by the different clients HARDCODED_CLIENTS = { ClientType::Web => { - name: "WEB", - version: "2.20220804.07.00", - api_key: DEFAULT_API_KEY, - screen: "WATCH_FULL_SCREEN", + name: "WEB", + version: "2.20220804.07.00", + api_key: DEFAULT_API_KEY, + screen: "WATCH_FULL_SCREEN", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", }, ClientType::WebEmbeddedPlayer => { - name: "WEB_EMBEDDED_PLAYER", # 56 - version: "1.20220803.01.00", - api_key: DEFAULT_API_KEY, - screen: "EMBED", + name: "WEB_EMBEDDED_PLAYER", # 56 + version: "1.20220803.01.00", + api_key: DEFAULT_API_KEY, + screen: "EMBED", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", }, ClientType::WebMobile => { - name: "MWEB", - version: "2.20220805.01.00", - api_key: DEFAULT_API_KEY, + name: "MWEB", + version: "2.20220805.01.00", + api_key: DEFAULT_API_KEY, + os_name: "Android", + os_version: ANDROID_VERSION, + platform: "MOBILE", }, ClientType::WebScreenEmbed => { - name: "WEB", - version: "2.20220804.00.00", - api_key: DEFAULT_API_KEY, - screen: "EMBED", + name: "WEB", + version: "2.20220804.00.00", + api_key: DEFAULT_API_KEY, + screen: "EMBED", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", }, # Android @@ -63,6 +78,10 @@ module YoutubeAPI version: ANDROID_APP_VERSION, api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", android_sdk_version: ANDROID_SDK_VERSION, + user_agent: ANDROID_USER_AGENT, + os_name: "Android", + os_version: ANDROID_VERSION, + platform: "MOBILE", }, ClientType::AndroidEmbeddedPlayer => { name: "ANDROID_EMBEDDED_PLAYER", # 55 @@ -75,6 +94,10 @@ module YoutubeAPI api_key: DEFAULT_API_KEY, screen: "EMBED", android_sdk_version: ANDROID_SDK_VERSION, + user_agent: ANDROID_USER_AGENT, + os_name: "Android", + os_version: ANDROID_VERSION, + platform: "MOBILE", }, # IOS @@ -179,6 +202,22 @@ module YoutubeAPI HARDCODED_CLIENTS[@client_type][:android_sdk_version]? end + def user_agent : String? + HARDCODED_CLIENTS[@client_type][:user_agent]? + end + + def os_name : String? + HARDCODED_CLIENTS[@client_type][:os_name]? + end + + def os_version : String? + HARDCODED_CLIENTS[@client_type][:os_version]? + end + + def platform : String? + HARDCODED_CLIENTS[@client_type][:platform]? + end + # Convert to string, for logging purposes def to_s return { @@ -226,6 +265,18 @@ module YoutubeAPI client_context["client"]["androidSdkVersion"] = android_sdk_version end + if os_name = client_config.os_name + client_context["client"]["osName"] = os_name + end + + if os_version = client_config.os_version + client_context["client"]["osVersion"] = os_version + end + + if platform = client_config.platform + client_context["client"]["platform"] = platform + end + return client_context end @@ -361,8 +412,18 @@ module YoutubeAPI ) # JSON Request data, required by the API data = { - "videoId" => video_id, - "context" => self.make_context(client_config), + "contentCheckOk" => true, + "videoId" => video_id, + "context" => self.make_context(client_config), + "racyCheckOk" => true, + "user" => { + "lockedSafetyMode" => false, + }, + "playbackContext" => { + "contentPlaybackContext" => { + "html5Preference": "HTML5_PREF_WANTS", + }, + }, } # Append the additional parameters if those were provided @@ -460,10 +521,15 @@ module YoutubeAPI url = "#{endpoint}?key=#{client_config.api_key}&prettyPrint=false" headers = HTTP::Headers{ - "Content-Type" => "application/json; charset=UTF-8", - "Accept-Encoding" => "gzip, deflate", + "Content-Type" => "application/json; charset=UTF-8", + "Accept-Encoding" => "gzip, deflate", + "x-goog-api-format-version" => "2", } + if user_agent = client_config.user_agent + headers["User-Agent"] = user_agent + end + # Logging LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}") -- cgit v1.2.3 From 6f3b4fbaaf0eecb5c26b199befcae4e305d86da1 Mon Sep 17 00:00:00 2001 From: Emilien Devos Date: Fri, 2 Sep 2022 20:16:02 +0200 Subject: fix replies count --- src/invidious/comments.cr | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 5112ad3d..d691ca36 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -201,15 +201,6 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b end if node_replies && !response["commentRepliesContinuation"]? - if node_replies["moreText"]? - reply_count = (node_replies["moreText"]["simpleText"]? || node_replies["moreText"]["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s.gsub(/\D/, "").to_i? || 1 - elsif node_replies["viewReplies"]? - reply_count = node_replies["viewReplies"]["buttonRenderer"]["text"]?.try &.["runs"][1]?.try &.["text"]?.try &.as_s.to_i? || 1 - else - reply_count = 1 - end - if node_replies["continuations"]? continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s elsif node_replies["contents"]? @@ -219,7 +210,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b json.field "replies" do json.object do - json.field "replyCount", reply_count + json.field "replyCount", node_comment["replyCount"]? || 1 json.field "continuation", continuation end end -- cgit v1.2.3 From 260bab598e00fe769ff36ba2c171768a1fbc31bb Mon Sep 17 00:00:00 2001 From: Emilien Devos Date: Fri, 2 Sep 2022 20:20:43 +0200 Subject: reword error messages --- locales/en-US.json | 2 +- src/invidious/routes/embed.cr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 5caa4bd1..5554b928 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -472,5 +472,5 @@ "crash_page_read_the_faq": "read the Frequently Asked Questions (FAQ)", "crash_page_search_issue": "searched for existing issues on GitHub", "crash_page_report_issue": "If none of the above helped, please open a new issue on GitHub (preferably in English) and include the following text in your message (do NOT translate that text):", - "video_not_exist_in_playlist": "The video requested doesn't exist in the playlist. Click here for the playlist home page." + "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. Click here for the playlist home page." } diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 7860f8b9..e6486587 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -10,7 +10,7 @@ module Invidious::Routes::Embed videos = get_playlist_videos(playlist, offset: offset) if videos.empty? url = "/playlist?list=#{plid}" - raise NotFoundException.new(translate(locale, "video_not_exist_in_playlist", url)) + raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url)) end rescue ex : NotFoundException return error_template(404, ex) @@ -70,7 +70,7 @@ module Invidious::Routes::Embed videos = get_playlist_videos(playlist, offset: offset) if videos.empty? url = "/playlist?list=#{plid}" - raise NotFoundException.new(translate(locale, "video_not_exist_in_playlist", url)) + raise NotFoundException.new(translate(locale, "error_video_not_in_playlist", url)) end rescue ex : NotFoundException return error_template(404, ex) -- cgit v1.2.3 From c658fd27cced47c438eb148f1a1aedf482be8f46 Mon Sep 17 00:00:00 2001 From: Emilien Devos Date: Fri, 2 Sep 2022 21:18:56 +0200 Subject: better spoof requests --- src/invidious/yt_backend/connection_pool.cr | 3 - src/invidious/yt_backend/youtube_api.cr | 103 +++++++++++++++++++++------- 2 files changed, 80 insertions(+), 26 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 23e98ae3..46e5bf85 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -13,9 +13,6 @@ def add_yt_headers(request) request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" request.headers["Accept-Language"] ||= "en-us,en;q=0.5" - return if request.resource.starts_with? "/sorry/index" - request.headers["x-youtube-client-name"] ||= "1" - request.headers["x-youtube-client-version"] ||= "2.20200609" # Preserve original cookies and add new YT consent cookie for EU servers request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" if !CONFIG.cookies.empty? diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index c014dc0e..02327025 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -8,11 +8,16 @@ module YoutubeAPI private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" private ANDROID_APP_VERSION = "17.33.42" - private ANDROID_USER_AGENT = "com.google.android.youtube/17.33.42 (Linux; U; Android 12; US)" + # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1308 + private ANDROID_USER_AGENT = "com.google.android.youtube/17.33.42 (Linux; U; Android 12; US) gzip" private ANDROID_SDK_VERSION = 31_i64 private ANDROID_VERSION = "12" private IOS_APP_VERSION = "17.33.2" - private WINDOWS_VERSION = "10.0" + # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1330 + private IOS_USER_AGENT = "com.google.ios.youtube/17.33.2 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)" + # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1224 + private IOS_VERSION = "15.6.0.19G71" + private WINDOWS_VERSION = "10.0" # Enumerate used to select one of the clients supported by the API enum ClientType @@ -37,6 +42,7 @@ module YoutubeAPI HARDCODED_CLIENTS = { ClientType::Web => { name: "WEB", + name_proto: "1", version: "2.20220804.07.00", api_key: DEFAULT_API_KEY, screen: "WATCH_FULL_SCREEN", @@ -45,7 +51,8 @@ module YoutubeAPI platform: "DESKTOP", }, ClientType::WebEmbeddedPlayer => { - name: "WEB_EMBEDDED_PLAYER", # 56 + name: "WEB_EMBEDDED_PLAYER", + name_proto: "56", version: "1.20220803.01.00", api_key: DEFAULT_API_KEY, screen: "EMBED", @@ -55,6 +62,7 @@ module YoutubeAPI }, ClientType::WebMobile => { name: "MWEB", + name_proto: "2", version: "2.20220805.01.00", api_key: DEFAULT_API_KEY, os_name: "Android", @@ -63,6 +71,7 @@ module YoutubeAPI }, ClientType::WebScreenEmbed => { name: "WEB", + name_proto: "1", version: "2.20220804.00.00", api_key: DEFAULT_API_KEY, screen: "EMBED", @@ -75,6 +84,7 @@ module YoutubeAPI ClientType::Android => { name: "ANDROID", + name_proto: "3", version: ANDROID_APP_VERSION, api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", android_sdk_version: ANDROID_SDK_VERSION, @@ -84,12 +94,14 @@ module YoutubeAPI platform: "MOBILE", }, ClientType::AndroidEmbeddedPlayer => { - name: "ANDROID_EMBEDDED_PLAYER", # 55 - version: ANDROID_APP_VERSION, - api_key: DEFAULT_API_KEY, + name: "ANDROID_EMBEDDED_PLAYER", + name_proto: "55", + version: ANDROID_APP_VERSION, + api_key: DEFAULT_API_KEY, }, ClientType::AndroidScreenEmbed => { - name: "ANDROID", # 3 + name: "ANDROID", + name_proto: "3", version: ANDROID_APP_VERSION, api_key: DEFAULT_API_KEY, screen: "EMBED", @@ -103,33 +115,56 @@ module YoutubeAPI # IOS ClientType::IOS => { - name: "IOS", # 5 - version: IOS_APP_VERSION, - api_key: "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc", + name: "IOS", + name_proto: "5", + version: IOS_APP_VERSION, + api_key: "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc", + user_agent: IOS_USER_AGENT, + device_make: "Apple", + device_model: "iPhone14,5", + os_name: "iPhone", + os_version: IOS_VERSION, + platform: "MOBILE", }, ClientType::IOSEmbedded => { - name: "IOS_MESSAGES_EXTENSION", # 66 - version: IOS_APP_VERSION, - api_key: DEFAULT_API_KEY, + name: "IOS_MESSAGES_EXTENSION", + name_proto: "66", + version: IOS_APP_VERSION, + api_key: DEFAULT_API_KEY, + user_agent: IOS_USER_AGENT, + device_make: "Apple", + device_model: "iPhone14,5", + os_name: "iPhone", + os_version: IOS_VERSION, + platform: "MOBILE", }, ClientType::IOSMusic => { - name: "IOS_MUSIC", # 26 - version: "4.32", - api_key: "AIzaSyBAETezhkwP0ZWA02RsqT1zu78Fpt0bC_s", + name: "IOS_MUSIC", + name_proto: "26", + version: "5.21", + api_key: "AIzaSyBAETezhkwP0ZWA02RsqT1zu78Fpt0bC_s", + user_agent: "com.google.ios.youtubemusic/5.21 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)", + device_make: "Apple", + device_model: "iPhone14,5", + os_name: "iPhone", + os_version: IOS_VERSION, + platform: "MOBILE", }, # TV app ClientType::TvHtml5 => { - name: "TVHTML5", # 7 - version: "7.20220325", - api_key: DEFAULT_API_KEY, + name: "TVHTML5", + name_proto: "7", + version: "7.20220325", + api_key: DEFAULT_API_KEY, }, ClientType::TvHtml5ScreenEmbed => { - name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", # 85 - version: "2.0", - api_key: DEFAULT_API_KEY, - screen: "EMBED", + name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", + name_proto: "85", + version: "2.0", + api_key: DEFAULT_API_KEY, + screen: "EMBED", }, } @@ -183,6 +218,10 @@ module YoutubeAPI HARDCODED_CLIENTS[@client_type][:name] end + def name_proto : String + HARDCODED_CLIENTS[@client_type][:name_proto] + end + # :ditto: def version : String HARDCODED_CLIENTS[@client_type][:version] @@ -210,6 +249,14 @@ module YoutubeAPI HARDCODED_CLIENTS[@client_type][:os_name]? end + def device_make : String? + HARDCODED_CLIENTS[@client_type][:device_make]? + end + + def device_model : String? + HARDCODED_CLIENTS[@client_type][:device_model]? + end + def os_version : String? HARDCODED_CLIENTS[@client_type][:os_version]? end @@ -265,6 +312,14 @@ module YoutubeAPI client_context["client"]["androidSdkVersion"] = android_sdk_version end + if device_make = client_config.device_make + client_context["client"]["deviceMake"] = device_make + end + + if device_model = client_config.device_model + client_context["client"]["deviceModel"] = device_model + end + if os_name = client_config.os_name client_context["client"]["osName"] = os_name end @@ -524,6 +579,8 @@ module YoutubeAPI "Content-Type" => "application/json; charset=UTF-8", "Accept-Encoding" => "gzip, deflate", "x-goog-api-format-version" => "2", + "x-youtube-client-name" => client_config.name_proto, + "x-youtube-client-version" => client_config.version, } if user_agent = client_config.user_agent -- cgit v1.2.3 From 7c45026383132c5aaf47b761ef132f5b0e635bb8 Mon Sep 17 00:00:00 2001 From: Jakub Filo Date: Wed, 28 Sep 2022 12:21:23 +0200 Subject: Fix playlist limit --- config/config.example.yml | 18 ++++++++---------- src/invidious/database/playlists.cr | 2 +- src/invidious/routes/subscriptions.cr | 2 +- src/invidious/user/imports.cr | 4 +++- 4 files changed, 13 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 424e2a38..160a2750 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -453,7 +453,13 @@ feed_threads: 1 ## #modified_source_code_url: "" - +## +## Maximum custom playlist length limit. +## +## Accepted values: Integer +## Default: 500 +## +#playlist_length_limit: 500 ######################################### # @@ -859,7 +865,7 @@ default_user_preferences: ## Default: false ## #automatic_instance_redirect: false - + ## ## Show the entire video description by default (when set to 'false', ## only the first few lines of the description are shown and a @@ -869,11 +875,3 @@ default_user_preferences: ## Default: false ## #extend_desc: false - - ## - ## Maximum custom playlist length limit. - ## - ## Accepted values: Integer - ## Default: 500 - ## - #playlist_length_limit: 500 diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index 5f47ff95..c6754a1e 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -248,7 +248,7 @@ module Invidious::Database::PlaylistVideos return PG_DB.query_one?(request, plid, index, as: String) end - def select_ids(plid : String, index : VideoIndex, limit = CONFIG.playlist_length_limit) : Array(String) + def select_ids(plid : String, index : VideoIndex, limit = 500) : Array(String) request = <<-SQL SELECT id FROM playlist_videos WHERE plid = $1 diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr index ed595d9a..7b1fa876 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -120,7 +120,7 @@ module Invidious::Routes::Subscriptions json.field "privacy", playlist.privacy.to_s json.field "videos" do json.array do - Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: CONFIG.playlist_length_limit).each do |video_id| + Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| json.string video_id end end diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index bd929e4d..20ae0d47 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -71,7 +71,9 @@ struct Invidious::User Invidious::Database::Playlists.update_description(playlist.id, description) videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| - raise InfoException.new("Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") if idx > 500 + if idx > CONFIG.playlist_length_limit + raise InfoException.new("Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") + end video_id = video_id.try &.as_s? next if !video_id -- cgit v1.2.3 From ffb42a9b23ec2b96a16984f1ec5cf21b7f0c1f44 Mon Sep 17 00:00:00 2001 From: thecashewtrader Date: Sat, 8 Oct 2022 15:13:02 +0530 Subject: Add channel name to embeds --- src/invidious/views/watch.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 243ea3a4..6fd6a401 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -9,7 +9,7 @@ "> - + -- cgit v1.2.3 From 3b39b8c772b57552893fa55eb417189b2976bbe4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 12 Oct 2022 10:06:36 +0200 Subject: Add table cleaning job (#3294) --- config/config.example.yml | 45 ++++++++++++++++++++++----- src/invidious.cr | 2 ++ src/invidious/config.cr | 4 +++ src/invidious/database/nonces.cr | 11 ++++++- src/invidious/database/videos.cr | 9 ++++++ src/invidious/jobs.cr | 27 ++++++++++++++++ src/invidious/jobs/base_job.cr | 30 ++++++++++++++++++ src/invidious/jobs/clear_expired_items_job.cr | 27 ++++++++++++++++ 8 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 src/invidious/jobs/clear_expired_items_job.cr (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 160a2750..264a5bea 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -304,10 +304,8 @@ https_only: false ## Number of threads to use when crawling channel videos (during ## subscriptions update). ## -## Notes: -## - Setting this to 0 will disable the channel videos crawl job. -## - This setting is overridden if "-c THREADS" or -## "--channel-threads=THREADS" are passed on the command line. +## Notes: This setting is overridden if either "-c THREADS" or +## "--channel-threads=THREADS" is passed on the command line. ## ## Accepted values: a positive integer ## Default: 1 @@ -335,10 +333,8 @@ full_refresh: false ## ## Number of threads to use when updating RSS feeds. ## -## Notes: -## - Setting this to 0 will disable the channel videos crawl job. -## - This setting is overridden if "-f THREADS" or -## "--feed-threads=THREADS" are passed on the command line. +## Notes: This setting is overridden if either "-f THREADS" or +## "--feed-threads=THREADS" is passed on the command line. ## ## Accepted values: a positive integer ## Default: 1 @@ -361,6 +357,39 @@ feed_threads: 1 #decrypt_polling: false +jobs: + + ## Options for the database cleaning job + clear_expired_items: + + ## Enable/Disable job + ## + ## Accepted values: true, false + ## Default: true + ## + enable: true + + ## Options for the channels updater job + refresh_channels: + + ## Enable/Disable job + ## + ## Accepted values: true, false + ## Default: true + ## + enable: true + + ## Options for the RSS feeds updater job + refresh_feeds: + + ## Enable/Disable job + ## + ## Accepted values: true, false + ## Default: true + ## + enable: true + + # ----------------------------- # Captcha API # ----------------------------- diff --git a/src/invidious.cr b/src/invidious.cr index 0601d5b2..58adaa35 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -172,6 +172,8 @@ end CONNECTION_CHANNEL = Channel({Bool, Channel(PQ::Notification)}).new(32) Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url) +Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new + Invidious::Jobs.start_all def popular_videos diff --git a/src/invidious/config.cr b/src/invidious/config.cr index f0873df4..c9bf43a4 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -78,6 +78,10 @@ class Config property decrypt_polling : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel property full_refresh : Bool = false + + # Jobs config structure. See jobs.cr and jobs/base_job.cr + property jobs = Invidious::Jobs::JobsConfig.new + # Used to tell Invidious it is behind a proxy, so links to resources should be https:// property https_only : Bool? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions diff --git a/src/invidious/database/nonces.cr b/src/invidious/database/nonces.cr index 469fcbd8..b87c81ec 100644 --- a/src/invidious/database/nonces.cr +++ b/src/invidious/database/nonces.cr @@ -4,7 +4,7 @@ module Invidious::Database::Nonces extend self # ------------------- - # Insert + # Insert / Delete # ------------------- def insert(nonce : String, expire : Time) @@ -17,6 +17,15 @@ module Invidious::Database::Nonces PG_DB.exec(request, nonce, expire) end + def delete_expired + request = <<-SQL + DELETE FROM nonces * + WHERE expire < now() + SQL + + PG_DB.exec(request) + end + # ------------------- # Update # ------------------- diff --git a/src/invidious/database/videos.cr b/src/invidious/database/videos.cr index e1fa01c3..695f5b33 100644 --- a/src/invidious/database/videos.cr +++ b/src/invidious/database/videos.cr @@ -22,6 +22,15 @@ module Invidious::Database::Videos PG_DB.exec(request, id) end + def delete_expired + request = <<-SQL + DELETE FROM videos * + WHERE updated < (now() - interval '6 hours') + SQL + + PG_DB.exec(request) + end + def update(video : Video) request = <<-SQL UPDATE videos diff --git a/src/invidious/jobs.cr b/src/invidious/jobs.cr index ec0cad64..524a3624 100644 --- a/src/invidious/jobs.cr +++ b/src/invidious/jobs.cr @@ -1,12 +1,39 @@ module Invidious::Jobs JOBS = [] of BaseJob + # Automatically generate a structure that wraps the various + # jobs' configs, so that the follwing YAML config can be used: + # + # jobs: + # job_name: + # enabled: true + # some_property: "value" + # + macro finished + struct JobsConfig + include YAML::Serializable + + {% for sc in BaseJob.subclasses %} + # Voodoo macro to transform `Some::Module::CustomJob` to `custom` + {% class_name = sc.id.split("::").last.id.gsub(/Job$/, "").underscore %} + + getter {{ class_name }} = {{ sc.name }}::Config.new + {% end %} + + def initialize + end + end + end + def self.register(job : BaseJob) JOBS << job end def self.start_all JOBS.each do |job| + # Don't run the main rountine if the job is disabled by config + next if job.disabled? + spawn { job.begin } end end diff --git a/src/invidious/jobs/base_job.cr b/src/invidious/jobs/base_job.cr index 47e75864..f90f0bfe 100644 --- a/src/invidious/jobs/base_job.cr +++ b/src/invidious/jobs/base_job.cr @@ -1,3 +1,33 @@ abstract class Invidious::Jobs::BaseJob abstract def begin + + # When this base job class is inherited, make sure to define + # a basic "Config" structure, that contains the "enable" property, + # and to create the associated instance property. + # + macro inherited + macro finished + # This config structure can be expanded as required. + struct Config + include YAML::Serializable + + property enable = true + + def initialize + end + end + + property cfg = Config.new + + # Return true if job is enabled by config + protected def enabled? : Bool + return (@cfg.enable == true) + end + + # Return true if job is disabled by config + protected def disabled? : Bool + return (@cfg.enable == false) + end + end + end end diff --git a/src/invidious/jobs/clear_expired_items_job.cr b/src/invidious/jobs/clear_expired_items_job.cr new file mode 100644 index 00000000..17191aac --- /dev/null +++ b/src/invidious/jobs/clear_expired_items_job.cr @@ -0,0 +1,27 @@ +class Invidious::Jobs::ClearExpiredItemsJob < Invidious::Jobs::BaseJob + # Remove items (videos, nonces, etc..) whose cache is outdated every hour. + # Removes the need for a cron job. + def begin + loop do + failed = false + + LOGGER.info("jobs: running ClearExpiredItems job") + + begin + Invidious::Database::Videos.delete_expired + Invidious::Database::Nonces.delete_expired + rescue DB::Error + failed = true + end + + # Retry earlier than scheduled on DB error + if failed + LOGGER.info("jobs: ClearExpiredItems failed. Retrying in 10 minutes.") + sleep 10.minutes + else + LOGGER.info("jobs: ClearExpiredItems done.") + sleep 1.hour + end + end + end +end -- cgit v1.2.3 From 6ea3673cf06404064b6aeb9fd22d75e2752a7dc0 Mon Sep 17 00:00:00 2001 From: thecashewtrader Date: Thu, 13 Oct 2022 21:44:16 +0530 Subject: Move uploader channel name to `og:site_name` --- src/invidious/views/watch.ecr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 6fd6a401..ae478378 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -7,9 +7,9 @@ "> - + - + -- cgit v1.2.3 From a1e0a6b499f8ccade4d382754aa36ccd157bd582 Mon Sep 17 00:00:00 2001 From: thecashewtrader Date: Sat, 15 Oct 2022 19:37:47 +0530 Subject: Add meta tags to channels --- src/invidious/views/channel.ecr | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 92f81ee4..9449305b 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -1,7 +1,21 @@ <% ucid = channel.ucid %> <% author = HTML.escape(channel.author) %> +<% channel_profile_pic = URI.parse(channel.author_thumbnail).request_target %> <% content_for "header" do %> + + + + + + + + + + + + + <%= author %> - Invidious <% end %> @@ -19,7 +33,7 @@
    - + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    -- cgit v1.2.3 From 6f301db11cdb77d29c0420663168386c2483825a Mon Sep 17 00:00:00 2001 From: thecashewtrader Date: Tue, 25 Oct 2022 15:25:58 +0530 Subject: Remove twitter:site meta tag from channel view --- src/invidious/views/channel.ecr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 9449305b..dea86abe 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -10,7 +10,6 @@ - -- cgit v1.2.3 From 1e96206b0b60275b462c58882655d84c73691977 Mon Sep 17 00:00:00 2001 From: thecashewtrader Date: Tue, 25 Oct 2022 15:49:45 +0530 Subject: Remove twitter:site meta tag from watch view --- src/invidious/views/watch.ecr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 243ea3a4..6cb2cdec 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -19,7 +19,6 @@ - -- cgit v1.2.3 From 4055c3bec86f4265c81282e59bddad21e5e348bd Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 30 Oct 2022 13:46:28 +0100 Subject: i18n: Add Bengali, Catalan, Basque, Sinhala and Slovak Add languages even if translation is <= 25% --- src/invidious/helpers/i18n.cr | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index fd86594c..a9ed1f64 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,8 +1,7 @@ -# "bn_BD" => load_locale("bn_BD"), # Bengali (Bangladesh) [Incomplete] -# "eu" => load_locale("eu"), # Basque [Incomplete] -# "sk" => load_locale("sk"), # Slovak [Incomplete] LOCALES_LIST = { "ar" => "العربية", # Arabic + "bn" => "বাংলা", # Bengali + "ca" => "Català", # Catalan "cs" => "Čeština", # Czech "da" => "Dansk", # Danish "de" => "Deutsch", # German @@ -11,6 +10,7 @@ LOCALES_LIST = { "eo" => "Esperanto", # Esperanto "es" => "Español", # Spanish "et" => "Eesti keel", # Estonian + "eu" => "Euskara", # Basque "fa" => "فارسی", # Persian "fi" => "Suomi", # Finnish "fr" => "Français", # French @@ -32,6 +32,8 @@ LOCALES_LIST = { "pt-PT" => "Português de Portugal", # Portuguese (Portugal) "ro" => "Română", # Romanian "ru" => "Русский", # Russian + "si" => "සිංහල", # Sinhala + "sk" => "Slovenčina", # Slovak "sl" => "Slovenščina", # Slovenian "sq" => "Shqip", # Albanian "sr" => "Srpski (latinica)", # Serbian (Latin) -- cgit v1.2.3 From 6250039405a0a9762a228066df16dd2e8579f4f3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 23 Aug 2022 18:23:53 +0200 Subject: videos: move regions list to a dedicated file --- src/invidious/videos.cr | 2 -- src/invidious/videos/regions.cr | 27 +++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 src/invidious/videos/regions.cr (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index c0ed6e85..97b4f4b8 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -131,8 +131,6 @@ CAPTION_LANGUAGES = { "Zulu", } -REGIONS = {"AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW"} - # See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 VIDEO_FORMATS = { "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, diff --git a/src/invidious/videos/regions.cr b/src/invidious/videos/regions.cr new file mode 100644 index 00000000..575f8c25 --- /dev/null +++ b/src/invidious/videos/regions.cr @@ -0,0 +1,27 @@ +# List of geographical regions that Youtube recognizes. +# This is used to determine if a video is either restricted to a list +# of allowed regions (= whitelisted) or if it can't be watched in +# a set of regions (= blacklisted). +REGIONS = { + "AD", "AE", "AF", "AG", "AI", "AL", "AM", "AO", "AQ", "AR", "AS", "AT", + "AU", "AW", "AX", "AZ", "BA", "BB", "BD", "BE", "BF", "BG", "BH", "BI", + "BJ", "BL", "BM", "BN", "BO", "BQ", "BR", "BS", "BT", "BV", "BW", "BY", + "BZ", "CA", "CC", "CD", "CF", "CG", "CH", "CI", "CK", "CL", "CM", "CN", + "CO", "CR", "CU", "CV", "CW", "CX", "CY", "CZ", "DE", "DJ", "DK", "DM", + "DO", "DZ", "EC", "EE", "EG", "EH", "ER", "ES", "ET", "FI", "FJ", "FK", + "FM", "FO", "FR", "GA", "GB", "GD", "GE", "GF", "GG", "GH", "GI", "GL", + "GM", "GN", "GP", "GQ", "GR", "GS", "GT", "GU", "GW", "GY", "HK", "HM", + "HN", "HR", "HT", "HU", "ID", "IE", "IL", "IM", "IN", "IO", "IQ", "IR", + "IS", "IT", "JE", "JM", "JO", "JP", "KE", "KG", "KH", "KI", "KM", "KN", + "KP", "KR", "KW", "KY", "KZ", "LA", "LB", "LC", "LI", "LK", "LR", "LS", + "LT", "LU", "LV", "LY", "MA", "MC", "MD", "ME", "MF", "MG", "MH", "MK", + "ML", "MM", "MN", "MO", "MP", "MQ", "MR", "MS", "MT", "MU", "MV", "MW", + "MX", "MY", "MZ", "NA", "NC", "NE", "NF", "NG", "NI", "NL", "NO", "NP", + "NR", "NU", "NZ", "OM", "PA", "PE", "PF", "PG", "PH", "PK", "PL", "PM", + "PN", "PR", "PS", "PT", "PW", "PY", "QA", "RE", "RO", "RS", "RU", "RW", + "SA", "SB", "SC", "SD", "SE", "SG", "SH", "SI", "SJ", "SK", "SL", "SM", + "SN", "SO", "SR", "SS", "ST", "SV", "SX", "SY", "SZ", "TC", "TD", "TF", + "TG", "TH", "TJ", "TK", "TL", "TM", "TN", "TO", "TR", "TT", "TV", "TW", + "TZ", "UA", "UG", "UM", "US", "UY", "UZ", "VA", "VC", "VE", "VG", "VI", + "VN", "VU", "WF", "WS", "YE", "YT", "ZA", "ZM", "ZW", +} -- cgit v1.2.3 From 88141c459c553bcca053643c0b1a2ae3338f75b9 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 23 May 2022 21:54:48 +0200 Subject: videos: move formats structure to a separate file/module --- src/invidious.cr | 1 + src/invidious/frontend/watch_page.cr | 2 +- src/invidious/videos.cr | 119 +---------------------------------- src/invidious/videos/formats.cr | 116 ++++++++++++++++++++++++++++++++++ 4 files changed, 120 insertions(+), 118 deletions(-) create mode 100644 src/invidious/videos/formats.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 58adaa35..8df0c0cd 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -37,6 +37,7 @@ require "./invidious/database/migrations/*" require "./invidious/helpers/*" require "./invidious/yt_backend/*" require "./invidious/frontend/*" +require "./invidious/videos/*" require "./invidious/*" require "./invidious/channels/*" diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index 80b67641..9212eb2f 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -50,7 +50,7 @@ module Invidious::Frontend::WatchPage video_assets.full_videos.each do |option| mimetype = option["mimeType"].as_s.split(";")[0] - height = itag_to_metadata?(option["itag"]).try &.["height"]? + height = Invidious::Videos::Formats.itag_to_metadata?(option["itag"]).try &.["height"]? value = {"itag": option["itag"], "ext": mimetype.split("/")[1]}.to_json diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 97b4f4b8..3a71f163 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -131,117 +131,6 @@ CAPTION_LANGUAGES = { "Zulu", } -# See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 -VIDEO_FORMATS = { - "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, - "6" => {"ext" => "flv", "width" => 450, "height" => 270, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, - "13" => {"ext" => "3gp", "acodec" => "aac", "vcodec" => "mp4v"}, - "17" => {"ext" => "3gp", "width" => 176, "height" => 144, "acodec" => "aac", "abr" => 24, "vcodec" => "mp4v"}, - "18" => {"ext" => "mp4", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 96, "vcodec" => "h264"}, - "22" => {"ext" => "mp4", "width" => 1280, "height" => 720, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "34" => {"ext" => "flv", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "35" => {"ext" => "flv", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - - "36" => {"ext" => "3gp", "width" => 320, "acodec" => "aac", "vcodec" => "mp4v"}, - "37" => {"ext" => "mp4", "width" => 1920, "height" => 1080, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "38" => {"ext" => "mp4", "width" => 4096, "height" => 3072, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "43" => {"ext" => "webm", "width" => 640, "height" => 360, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "44" => {"ext" => "webm", "width" => 854, "height" => 480, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "45" => {"ext" => "webm", "width" => 1280, "height" => 720, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "46" => {"ext" => "webm", "width" => 1920, "height" => 1080, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "59" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "78" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - - # 3D videos - "82" => {"ext" => "mp4", "height" => 360, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "83" => {"ext" => "mp4", "height" => 480, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "84" => {"ext" => "mp4", "height" => 720, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "85" => {"ext" => "mp4", "height" => 1080, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, - "100" => {"ext" => "webm", "height" => 360, "format" => "3D", "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, - "101" => {"ext" => "webm", "height" => 480, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - "102" => {"ext" => "webm", "height" => 720, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, - - # Apple HTTP Live Streaming - "91" => {"ext" => "mp4", "height" => 144, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "92" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "93" => {"ext" => "mp4", "height" => 360, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "94" => {"ext" => "mp4", "height" => 480, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, - "95" => {"ext" => "mp4", "height" => 720, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, - "96" => {"ext" => "mp4", "height" => 1080, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, - "132" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, - "151" => {"ext" => "mp4", "height" => 72, "format" => "HLS", "acodec" => "aac", "abr" => 24, "vcodec" => "h264"}, - - # DASH mp4 video - "133" => {"ext" => "mp4", "height" => 240, "format" => "DASH video", "vcodec" => "h264"}, - "134" => {"ext" => "mp4", "height" => 360, "format" => "DASH video", "vcodec" => "h264"}, - "135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, - "136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"}, - "137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"}, - "138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559) - "160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"}, - "212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, - "264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"}, - "298" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, - "299" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, - "266" => {"ext" => "mp4", "height" => 2160, "format" => "DASH video", "vcodec" => "h264"}, - - # Dash mp4 audio - "139" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 48, "container" => "m4a_dash"}, - "140" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 128, "container" => "m4a_dash"}, - "141" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 256, "container" => "m4a_dash"}, - "256" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, - "258" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, - "325" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "dtse", "container" => "m4a_dash"}, - "328" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "ec-3", "container" => "m4a_dash"}, - - # Dash webm - "167" => {"ext" => "webm", "height" => 360, "width" => 640, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "168" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "169" => {"ext" => "webm", "height" => 720, "width" => 1280, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "170" => {"ext" => "webm", "height" => 1080, "width" => 1920, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "218" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "219" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, - "278" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "container" => "webm", "vcodec" => "vp9"}, - "242" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9"}, - "243" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9"}, - "244" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "245" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "246" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, - "247" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9"}, - "248" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9"}, - "271" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9"}, - # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug) - "272" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, - "302" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "303" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "308" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "313" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, - "315" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "330" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "331" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "332" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "333" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "334" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "335" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "336" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - "337" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, - - # Dash webm audio - "171" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 128}, - "172" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 256}, - - # Dash webm audio with opus inside - "249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50}, - "250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70}, - "251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160}, - - # av01 video only formats sometimes served with "unknown" codecs - "394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"}, - "395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"}, - "396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"}, - "397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"}, -} - struct VideoPreferences include JSON::Serializable @@ -390,7 +279,7 @@ struct Video json.field "lmt", fmt["lastModified"] json.field "projectionType", fmt["projectionType"] - if fmt_info = itag_to_metadata?(fmt["itag"]) + if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 json.field "fps", fps json.field "container", fmt_info["ext"] @@ -437,7 +326,7 @@ struct Video json.field "type", fmt["mimeType"] json.field "quality", fmt["quality"] - fmt_info = itag_to_metadata?(fmt["itag"]) + fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) if fmt_info fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 json.field "fps", fps @@ -1164,10 +1053,6 @@ def fetch_video(id, region) return video end -def itag_to_metadata?(itag : JSON::Any) - return VIDEO_FORMATS[itag.to_s]? -end - def process_continuation(query, plid, id) continuation = nil if plid diff --git a/src/invidious/videos/formats.cr b/src/invidious/videos/formats.cr new file mode 100644 index 00000000..e98e7257 --- /dev/null +++ b/src/invidious/videos/formats.cr @@ -0,0 +1,116 @@ +module Invidious::Videos::Formats + def self.itag_to_metadata?(itag : JSON::Any) + return FORMATS[itag.to_s]? + end + + # See https://github.com/rg3/youtube-dl/blob/master/youtube_dl/extractor/youtube.py#L380-#L476 + private FORMATS = { + "5" => {"ext" => "flv", "width" => 400, "height" => 240, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, + "6" => {"ext" => "flv", "width" => 450, "height" => 270, "acodec" => "mp3", "abr" => 64, "vcodec" => "h263"}, + "13" => {"ext" => "3gp", "acodec" => "aac", "vcodec" => "mp4v"}, + "17" => {"ext" => "3gp", "width" => 176, "height" => 144, "acodec" => "aac", "abr" => 24, "vcodec" => "mp4v"}, + "18" => {"ext" => "mp4", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 96, "vcodec" => "h264"}, + "22" => {"ext" => "mp4", "width" => 1280, "height" => 720, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "34" => {"ext" => "flv", "width" => 640, "height" => 360, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "35" => {"ext" => "flv", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + + "36" => {"ext" => "3gp", "width" => 320, "acodec" => "aac", "vcodec" => "mp4v"}, + "37" => {"ext" => "mp4", "width" => 1920, "height" => 1080, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "38" => {"ext" => "mp4", "width" => 4096, "height" => 3072, "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "43" => {"ext" => "webm", "width" => 640, "height" => 360, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "44" => {"ext" => "webm", "width" => 854, "height" => 480, "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "45" => {"ext" => "webm", "width" => 1280, "height" => 720, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "46" => {"ext" => "webm", "width" => 1920, "height" => 1080, "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "59" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "78" => {"ext" => "mp4", "width" => 854, "height" => 480, "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + + # 3D videos + "82" => {"ext" => "mp4", "height" => 360, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "83" => {"ext" => "mp4", "height" => 480, "format" => "3D", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "84" => {"ext" => "mp4", "height" => 720, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "85" => {"ext" => "mp4", "height" => 1080, "format" => "3D", "acodec" => "aac", "abr" => 192, "vcodec" => "h264"}, + "100" => {"ext" => "webm", "height" => 360, "format" => "3D", "acodec" => "vorbis", "abr" => 128, "vcodec" => "vp8"}, + "101" => {"ext" => "webm", "height" => 480, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + "102" => {"ext" => "webm", "height" => 720, "format" => "3D", "acodec" => "vorbis", "abr" => 192, "vcodec" => "vp8"}, + + # Apple HTTP Live Streaming + "91" => {"ext" => "mp4", "height" => 144, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "92" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "93" => {"ext" => "mp4", "height" => 360, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "94" => {"ext" => "mp4", "height" => 480, "format" => "HLS", "acodec" => "aac", "abr" => 128, "vcodec" => "h264"}, + "95" => {"ext" => "mp4", "height" => 720, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, + "96" => {"ext" => "mp4", "height" => 1080, "format" => "HLS", "acodec" => "aac", "abr" => 256, "vcodec" => "h264"}, + "132" => {"ext" => "mp4", "height" => 240, "format" => "HLS", "acodec" => "aac", "abr" => 48, "vcodec" => "h264"}, + "151" => {"ext" => "mp4", "height" => 72, "format" => "HLS", "acodec" => "aac", "abr" => 24, "vcodec" => "h264"}, + + # DASH mp4 video + "133" => {"ext" => "mp4", "height" => 240, "format" => "DASH video", "vcodec" => "h264"}, + "134" => {"ext" => "mp4", "height" => 360, "format" => "DASH video", "vcodec" => "h264"}, + "135" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, + "136" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264"}, + "137" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264"}, + "138" => {"ext" => "mp4", "format" => "DASH video", "vcodec" => "h264"}, # Height can vary (https://github.com/ytdl-org/youtube-dl/issues/4559) + "160" => {"ext" => "mp4", "height" => 144, "format" => "DASH video", "vcodec" => "h264"}, + "212" => {"ext" => "mp4", "height" => 480, "format" => "DASH video", "vcodec" => "h264"}, + "264" => {"ext" => "mp4", "height" => 1440, "format" => "DASH video", "vcodec" => "h264"}, + "298" => {"ext" => "mp4", "height" => 720, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, + "299" => {"ext" => "mp4", "height" => 1080, "format" => "DASH video", "vcodec" => "h264", "fps" => 60}, + "266" => {"ext" => "mp4", "height" => 2160, "format" => "DASH video", "vcodec" => "h264"}, + + # Dash mp4 audio + "139" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 48, "container" => "m4a_dash"}, + "140" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 128, "container" => "m4a_dash"}, + "141" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "abr" => 256, "container" => "m4a_dash"}, + "256" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, + "258" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "aac", "container" => "m4a_dash"}, + "325" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "dtse", "container" => "m4a_dash"}, + "328" => {"ext" => "m4a", "format" => "DASH audio", "acodec" => "ec-3", "container" => "m4a_dash"}, + + # Dash webm + "167" => {"ext" => "webm", "height" => 360, "width" => 640, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "168" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "169" => {"ext" => "webm", "height" => 720, "width" => 1280, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "170" => {"ext" => "webm", "height" => 1080, "width" => 1920, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "218" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "219" => {"ext" => "webm", "height" => 480, "width" => 854, "format" => "DASH video", "container" => "webm", "vcodec" => "vp8"}, + "278" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "container" => "webm", "vcodec" => "vp9"}, + "242" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9"}, + "243" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9"}, + "244" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "245" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "246" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9"}, + "247" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9"}, + "248" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9"}, + "271" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9"}, + # itag 272 videos are either 3840x2160 (e.g. RtoitU2A-3E) or 7680x4320 (sLprVF6d7Ug) + "272" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, + "302" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "303" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "308" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "313" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9"}, + "315" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "330" => {"ext" => "webm", "height" => 144, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "331" => {"ext" => "webm", "height" => 240, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "332" => {"ext" => "webm", "height" => 360, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "333" => {"ext" => "webm", "height" => 480, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "334" => {"ext" => "webm", "height" => 720, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "335" => {"ext" => "webm", "height" => 1080, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "336" => {"ext" => "webm", "height" => 1440, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + "337" => {"ext" => "webm", "height" => 2160, "format" => "DASH video", "vcodec" => "vp9", "fps" => 60}, + + # Dash webm audio + "171" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 128}, + "172" => {"ext" => "webm", "acodec" => "vorbis", "format" => "DASH audio", "abr" => 256}, + + # Dash webm audio with opus inside + "249" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 50}, + "250" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 70}, + "251" => {"ext" => "webm", "format" => "DASH audio", "acodec" => "opus", "abr" => 160}, + + # av01 video only formats sometimes served with "unknown" codecs + "394" => {"ext" => "mp4", "height" => 144, "vcodec" => "av01.0.05M.08"}, + "395" => {"ext" => "mp4", "height" => 240, "vcodec" => "av01.0.05M.08"}, + "396" => {"ext" => "mp4", "height" => 360, "vcodec" => "av01.0.05M.08"}, + "397" => {"ext" => "mp4", "height" => 480, "vcodec" => "av01.0.05M.08"}, + } +end -- cgit v1.2.3 From 9baaef412fe1615eac4fd1508ef879de6b7a8805 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 23 May 2022 22:29:10 +0200 Subject: videos: move 'VideoPreferences' and its associated function to a separate file This will require some rework later. --- src/invidious/videos.cr | 157 ------------------------------ src/invidious/videos/video_preferences.cr | 156 +++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 157 deletions(-) create mode 100644 src/invidious/videos/video_preferences.cr (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 3a71f163..f4012666 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -131,34 +131,6 @@ CAPTION_LANGUAGES = { "Zulu", } -struct VideoPreferences - include JSON::Serializable - - property annotations : Bool - property autoplay : Bool - property comments : Array(String) - property continue : Bool - property continue_autoplay : Bool - property controls : Bool - property listen : Bool - property local : Bool - property preferred_captions : Array(String) - property player_style : String - property quality : String - property quality_dash : String - property raw : Bool - property region : String? - property related_videos : Bool - property speed : Float32 | Float64 - property video_end : Float64 | Int32 - property video_loop : Bool - property extend_desc : Bool - property video_start : Float64 | Int32 - property volume : Int32 - property vr_mode : Bool - property save_player_pos : Bool -end - struct Video include DB::Serializable @@ -1067,135 +1039,6 @@ def process_continuation(query, plid, id) continuation end -def process_video_params(query, preferences) - annotations = query["iv_load_policy"]?.try &.to_i? - autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } - comments = query["comments"]?.try &.split(",").map(&.downcase) - 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(&.downcase) - quality = query["quality"]? - quality_dash = query["quality_dash"]? - region = query["region"]? - 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 { |q| (q == "true" || q == "1").to_unsafe } - extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } - volume = query["volume"]?.try &.to_i? - vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } - save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe } - - if preferences - # region ||= preferences.region - annotations ||= preferences.annotations.to_unsafe - autoplay ||= preferences.autoplay.to_unsafe - comments ||= preferences.comments - continue ||= preferences.continue.to_unsafe - continue_autoplay ||= preferences.continue_autoplay.to_unsafe - listen ||= preferences.listen.to_unsafe - local ||= preferences.local.to_unsafe - player_style ||= preferences.player_style - preferred_captions ||= preferences.captions - quality ||= preferences.quality - quality_dash ||= preferences.quality_dash - related_videos ||= preferences.related_videos.to_unsafe - speed ||= preferences.speed - video_loop ||= preferences.video_loop.to_unsafe - extend_desc ||= preferences.extend_desc.to_unsafe - volume ||= preferences.volume - vr_mode ||= preferences.vr_mode.to_unsafe - save_player_pos ||= preferences.save_player_pos.to_unsafe - end - - annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe - autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe - comments ||= CONFIG.default_user_preferences.comments - continue ||= CONFIG.default_user_preferences.continue.to_unsafe - continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe - listen ||= CONFIG.default_user_preferences.listen.to_unsafe - local ||= CONFIG.default_user_preferences.local.to_unsafe - player_style ||= CONFIG.default_user_preferences.player_style - preferred_captions ||= CONFIG.default_user_preferences.captions - quality ||= CONFIG.default_user_preferences.quality - quality_dash ||= CONFIG.default_user_preferences.quality_dash - related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe - speed ||= CONFIG.default_user_preferences.speed - video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe - extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe - volume ||= CONFIG.default_user_preferences.volume - vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe - save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe - - annotations = annotations == 1 - autoplay = autoplay == 1 - continue = continue == 1 - continue_autoplay = continue_autoplay == 1 - listen = listen == 1 - local = local == 1 - related_videos = related_videos == 1 - video_loop = video_loop == 1 - extend_desc = extend_desc == 1 - vr_mode = vr_mode == 1 - save_player_pos = save_player_pos == 1 - - if CONFIG.disabled?("dash") && quality == "dash" - quality = "high" - end - - if CONFIG.disabled?("local") && local - local = false - end - - if start = query["t"]? || query["time_continue"]? || query["start"]? - video_start = decode_time(start) - end - video_start ||= 0 - - if query["end"]? - video_end = decode_time(query["end"]) - end - video_end ||= -1 - - raw = query["raw"]?.try &.to_i? - raw ||= 0 - raw = raw == 1 - - controls = query["controls"]?.try &.to_i? - controls ||= 1 - controls = controls >= 1 - - params = VideoPreferences.new({ - annotations: annotations, - autoplay: autoplay, - comments: comments, - continue: continue, - continue_autoplay: continue_autoplay, - controls: controls, - listen: listen, - local: local, - player_style: player_style, - preferred_captions: preferred_captions, - quality: quality, - quality_dash: quality_dash, - raw: raw, - region: region, - related_videos: related_videos, - speed: speed, - video_end: video_end, - video_loop: video_loop, - extend_desc: extend_desc, - video_start: video_start, - volume: volume, - vr_mode: vr_mode, - save_player_pos: save_player_pos, - }) - - return params -end - def build_thumbnails(id) return { {host: HOST_URL, height: 720, width: 1280, name: "maxres", url: "maxres"}, diff --git a/src/invidious/videos/video_preferences.cr b/src/invidious/videos/video_preferences.cr new file mode 100644 index 00000000..34cf7ff0 --- /dev/null +++ b/src/invidious/videos/video_preferences.cr @@ -0,0 +1,156 @@ +struct VideoPreferences + include JSON::Serializable + + property annotations : Bool + property autoplay : Bool + property comments : Array(String) + property continue : Bool + property continue_autoplay : Bool + property controls : Bool + property listen : Bool + property local : Bool + property preferred_captions : Array(String) + property player_style : String + property quality : String + property quality_dash : String + property raw : Bool + property region : String? + property related_videos : Bool + property speed : Float32 | Float64 + property video_end : Float64 | Int32 + property video_loop : Bool + property extend_desc : Bool + property video_start : Float64 | Int32 + property volume : Int32 + property vr_mode : Bool + property save_player_pos : Bool +end + +def process_video_params(query, preferences) + annotations = query["iv_load_policy"]?.try &.to_i? + autoplay = query["autoplay"]?.try { |q| (q == "true" || q == "1").to_unsafe } + comments = query["comments"]?.try &.split(",").map(&.downcase) + 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(&.downcase) + quality = query["quality"]? + quality_dash = query["quality_dash"]? + region = query["region"]? + 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 { |q| (q == "true" || q == "1").to_unsafe } + extend_desc = query["extend_desc"]?.try { |q| (q == "true" || q == "1").to_unsafe } + volume = query["volume"]?.try &.to_i? + vr_mode = query["vr_mode"]?.try { |q| (q == "true" || q == "1").to_unsafe } + save_player_pos = query["save_player_pos"]?.try { |q| (q == "true" || q == "1").to_unsafe } + + if preferences + # region ||= preferences.region + annotations ||= preferences.annotations.to_unsafe + autoplay ||= preferences.autoplay.to_unsafe + comments ||= preferences.comments + continue ||= preferences.continue.to_unsafe + continue_autoplay ||= preferences.continue_autoplay.to_unsafe + listen ||= preferences.listen.to_unsafe + local ||= preferences.local.to_unsafe + player_style ||= preferences.player_style + preferred_captions ||= preferences.captions + quality ||= preferences.quality + quality_dash ||= preferences.quality_dash + related_videos ||= preferences.related_videos.to_unsafe + speed ||= preferences.speed + video_loop ||= preferences.video_loop.to_unsafe + extend_desc ||= preferences.extend_desc.to_unsafe + volume ||= preferences.volume + vr_mode ||= preferences.vr_mode.to_unsafe + save_player_pos ||= preferences.save_player_pos.to_unsafe + end + + annotations ||= CONFIG.default_user_preferences.annotations.to_unsafe + autoplay ||= CONFIG.default_user_preferences.autoplay.to_unsafe + comments ||= CONFIG.default_user_preferences.comments + continue ||= CONFIG.default_user_preferences.continue.to_unsafe + continue_autoplay ||= CONFIG.default_user_preferences.continue_autoplay.to_unsafe + listen ||= CONFIG.default_user_preferences.listen.to_unsafe + local ||= CONFIG.default_user_preferences.local.to_unsafe + player_style ||= CONFIG.default_user_preferences.player_style + preferred_captions ||= CONFIG.default_user_preferences.captions + quality ||= CONFIG.default_user_preferences.quality + quality_dash ||= CONFIG.default_user_preferences.quality_dash + related_videos ||= CONFIG.default_user_preferences.related_videos.to_unsafe + speed ||= CONFIG.default_user_preferences.speed + video_loop ||= CONFIG.default_user_preferences.video_loop.to_unsafe + extend_desc ||= CONFIG.default_user_preferences.extend_desc.to_unsafe + volume ||= CONFIG.default_user_preferences.volume + vr_mode ||= CONFIG.default_user_preferences.vr_mode.to_unsafe + save_player_pos ||= CONFIG.default_user_preferences.save_player_pos.to_unsafe + + annotations = annotations == 1 + autoplay = autoplay == 1 + continue = continue == 1 + continue_autoplay = continue_autoplay == 1 + listen = listen == 1 + local = local == 1 + related_videos = related_videos == 1 + video_loop = video_loop == 1 + extend_desc = extend_desc == 1 + vr_mode = vr_mode == 1 + save_player_pos = save_player_pos == 1 + + if CONFIG.disabled?("dash") && quality == "dash" + quality = "high" + end + + if CONFIG.disabled?("local") && local + local = false + end + + if start = query["t"]? || query["time_continue"]? || query["start"]? + video_start = decode_time(start) + end + video_start ||= 0 + + if query["end"]? + video_end = decode_time(query["end"]) + end + video_end ||= -1 + + raw = query["raw"]?.try &.to_i? + raw ||= 0 + raw = raw == 1 + + controls = query["controls"]?.try &.to_i? + controls ||= 1 + controls = controls >= 1 + + params = VideoPreferences.new({ + annotations: annotations, + autoplay: autoplay, + comments: comments, + continue: continue, + continue_autoplay: continue_autoplay, + controls: controls, + listen: listen, + local: local, + player_style: player_style, + preferred_captions: preferred_captions, + quality: quality, + quality_dash: quality_dash, + raw: raw, + region: region, + related_videos: related_videos, + speed: speed, + video_end: video_end, + video_loop: video_loop, + extend_desc: extend_desc, + video_start: video_start, + volume: volume, + vr_mode: vr_mode, + save_player_pos: save_player_pos, + }) + + return params +end -- cgit v1.2.3 From cd03fa06aee7596446c0bbe9b77c3832419e8146 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 23 May 2022 22:37:58 +0200 Subject: videos: move 'Caption' and associated global/functions to a separate file --- spec/spec_helper.cr | 1 + src/invidious/frontend/watch_page.cr | 2 +- src/invidious/videos.cr | 168 ++----------------------------- src/invidious/videos/caption.cr | 168 +++++++++++++++++++++++++++++++ src/invidious/views/user/preferences.ecr | 2 +- 5 files changed, 177 insertions(+), 164 deletions(-) create mode 100644 src/invidious/videos/caption.cr (limited to 'src') diff --git a/spec/spec_helper.cr b/spec/spec_helper.cr index 6c492e2f..f8bfa718 100644 --- a/spec/spec_helper.cr +++ b/spec/spec_helper.cr @@ -5,6 +5,7 @@ require "protodec/utils" require "yaml" require "../src/invidious/helpers/*" require "../src/invidious/channels/*" +require "../src/invidious/videos/caption" require "../src/invidious/videos" require "../src/invidious/comments" require "../src/invidious/playlists" diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index 9212eb2f..a9b00860 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -7,7 +7,7 @@ module Invidious::Frontend::WatchPage getter full_videos : Array(Hash(String, JSON::Any)) getter video_streams : Array(Hash(String, JSON::Any)) getter audio_streams : Array(Hash(String, JSON::Any)) - getter captions : Array(Caption) + getter captions : Array(Invidious::Videos::Caption) def initialize( @full_videos, diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index f4012666..45a44c29 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1,136 +1,3 @@ -CAPTION_LANGUAGES = { - "", - "English", - "English (auto-generated)", - "English (United Kingdom)", - "English (United States)", - "Afrikaans", - "Albanian", - "Amharic", - "Arabic", - "Armenian", - "Azerbaijani", - "Bangla", - "Basque", - "Belarusian", - "Bosnian", - "Bulgarian", - "Burmese", - "Cantonese (Hong Kong)", - "Catalan", - "Cebuano", - "Chinese", - "Chinese (China)", - "Chinese (Hong Kong)", - "Chinese (Simplified)", - "Chinese (Taiwan)", - "Chinese (Traditional)", - "Corsican", - "Croatian", - "Czech", - "Danish", - "Dutch", - "Dutch (auto-generated)", - "Esperanto", - "Estonian", - "Filipino", - "Finnish", - "French", - "French (auto-generated)", - "Galician", - "Georgian", - "German", - "German (auto-generated)", - "Greek", - "Gujarati", - "Haitian Creole", - "Hausa", - "Hawaiian", - "Hebrew", - "Hindi", - "Hmong", - "Hungarian", - "Icelandic", - "Igbo", - "Indonesian", - "Indonesian (auto-generated)", - "Interlingue", - "Irish", - "Italian", - "Italian (auto-generated)", - "Japanese", - "Japanese (auto-generated)", - "Javanese", - "Kannada", - "Kazakh", - "Khmer", - "Korean", - "Korean (auto-generated)", - "Kurdish", - "Kyrgyz", - "Lao", - "Latin", - "Latvian", - "Lithuanian", - "Luxembourgish", - "Macedonian", - "Malagasy", - "Malay", - "Malayalam", - "Maltese", - "Maori", - "Marathi", - "Mongolian", - "Nepali", - "Norwegian Bokmål", - "Nyanja", - "Pashto", - "Persian", - "Polish", - "Portuguese", - "Portuguese (auto-generated)", - "Portuguese (Brazil)", - "Punjabi", - "Romanian", - "Russian", - "Russian (auto-generated)", - "Samoan", - "Scottish Gaelic", - "Serbian", - "Shona", - "Sindhi", - "Sinhala", - "Slovak", - "Slovenian", - "Somali", - "Southern Sotho", - "Spanish", - "Spanish (auto-generated)", - "Spanish (Latin America)", - "Spanish (Mexico)", - "Spanish (Spain)", - "Sundanese", - "Swahili", - "Swedish", - "Tajik", - "Tamil", - "Telugu", - "Thai", - "Turkish", - "Turkish (auto-generated)", - "Ukrainian", - "Urdu", - "Uzbek", - "Vietnamese", - "Vietnamese (auto-generated)", - "Welsh", - "Western Frisian", - "Xhosa", - "Yiddish", - "Yoruba", - "Zulu", -} - struct Video include DB::Serializable @@ -141,7 +8,7 @@ struct Video property updated : Time @[DB::Field(ignore: true)] - property captions : Array(Caption)? + @captions = [] of Invidious::Videos::Caption @[DB::Field(ignore: true)] property adaptive_fmts : Array(Hash(String, JSON::Any))? @@ -595,20 +462,12 @@ struct Video keywords.includes? "YouTube Red" end - def captions : Array(Caption) - return @captions.as(Array(Caption)) if @captions - captions = info["captions"]?.try &.["playerCaptionsTracklistRenderer"]?.try &.["captionTracks"]?.try &.as_a.map do |caption| - name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] - language_code = caption["languageCode"].to_s - base_url = caption["baseUrl"].to_s - - caption = Caption.new(name.to_s, language_code, base_url) - caption.name = caption.name.split(" - ")[0] - caption + def captions : Array(Invidious::Videos::Caption) + if @captions.empty? && @info.has_key?("captions") + @captions = Invidious::Videos::Caption.from_yt_json(info["captions"]) end - captions ||= [] of Caption - @captions = captions - return @captions.as(Array(Caption)) + + return @captions end def description @@ -672,21 +531,6 @@ struct Video end end -struct Caption - property name - property language_code - property base_url - - getter name : String - getter language_code : String - getter base_url : String - - setter name - - def initialize(@name, @language_code, @base_url) - end -end - class VideoRedirect < Exception property video_id : String diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr new file mode 100644 index 00000000..4642c1a7 --- /dev/null +++ b/src/invidious/videos/caption.cr @@ -0,0 +1,168 @@ +require "json" + +module Invidious::Videos + struct Caption + property name : String + property language_code : String + property base_url : String + + def initialize(@name, @language_code, @base_url) + end + + # Parse the JSON structure from Youtube + def self.from_yt_json(container : JSON::Any) : Array(Caption) + caption_tracks = container + .dig?("playerCaptionsTracklistRenderer", "captionTracks") + .try &.as_a + + captions_list = [] of Caption + return captions_list if caption_tracks.nil? + + caption_tracks.each do |caption| + name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] + name = name.to_s.split(" - ")[0] + + language_code = caption["languageCode"].to_s + base_url = caption["baseUrl"].to_s + + captions_list << Caption.new(name, language_code, base_url) + end + + return captions_list + end + + # List of all caption languages available on Youtube. + LANGUAGES = { + "", + "English", + "English (auto-generated)", + "English (United Kingdom)", + "English (United States)", + "Afrikaans", + "Albanian", + "Amharic", + "Arabic", + "Armenian", + "Azerbaijani", + "Bangla", + "Basque", + "Belarusian", + "Bosnian", + "Bulgarian", + "Burmese", + "Cantonese (Hong Kong)", + "Catalan", + "Cebuano", + "Chinese", + "Chinese (China)", + "Chinese (Hong Kong)", + "Chinese (Simplified)", + "Chinese (Taiwan)", + "Chinese (Traditional)", + "Corsican", + "Croatian", + "Czech", + "Danish", + "Dutch", + "Dutch (auto-generated)", + "Esperanto", + "Estonian", + "Filipino", + "Finnish", + "French", + "French (auto-generated)", + "Galician", + "Georgian", + "German", + "German (auto-generated)", + "Greek", + "Gujarati", + "Haitian Creole", + "Hausa", + "Hawaiian", + "Hebrew", + "Hindi", + "Hmong", + "Hungarian", + "Icelandic", + "Igbo", + "Indonesian", + "Indonesian (auto-generated)", + "Interlingue", + "Irish", + "Italian", + "Italian (auto-generated)", + "Japanese", + "Japanese (auto-generated)", + "Javanese", + "Kannada", + "Kazakh", + "Khmer", + "Korean", + "Korean (auto-generated)", + "Kurdish", + "Kyrgyz", + "Lao", + "Latin", + "Latvian", + "Lithuanian", + "Luxembourgish", + "Macedonian", + "Malagasy", + "Malay", + "Malayalam", + "Maltese", + "Maori", + "Marathi", + "Mongolian", + "Nepali", + "Norwegian Bokmål", + "Nyanja", + "Pashto", + "Persian", + "Polish", + "Portuguese", + "Portuguese (auto-generated)", + "Portuguese (Brazil)", + "Punjabi", + "Romanian", + "Russian", + "Russian (auto-generated)", + "Samoan", + "Scottish Gaelic", + "Serbian", + "Shona", + "Sindhi", + "Sinhala", + "Slovak", + "Slovenian", + "Somali", + "Southern Sotho", + "Spanish", + "Spanish (auto-generated)", + "Spanish (Latin America)", + "Spanish (Mexico)", + "Spanish (Spain)", + "Sundanese", + "Swahili", + "Swedish", + "Tajik", + "Tamil", + "Telugu", + "Thai", + "Turkish", + "Turkish (auto-generated)", + "Ukrainian", + "Urdu", + "Uzbek", + "Vietnamese", + "Vietnamese (auto-generated)", + "Welsh", + "Western Frisian", + "Xhosa", + "Yiddish", + "Yoruba", + "Zulu", + } + end +end diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index dbb5e9db..d841982c 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -89,7 +89,7 @@ <% preferences.captions.each_with_index do |caption, index| %> -- cgit v1.2.3 From 6aaea7fafa72aecc224089a6b52cad6e6d6daa0f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 24 Jul 2022 23:30:58 +0200 Subject: Videos: parse data during first fetching There will be less data to be stores in the DB cache --- .../videos/scheduled_live_extract_spec.cr | 2 - src/invidious/videos.cr | 300 +++++++++++++-------- 2 files changed, 182 insertions(+), 120 deletions(-) (limited to 'src') diff --git a/spec/invidious/videos/scheduled_live_extract_spec.cr b/spec/invidious/videos/scheduled_live_extract_spec.cr index 6e531bbd..b80aec0c 100644 --- a/spec/invidious/videos/scheduled_live_extract_spec.cr +++ b/spec/invidious/videos/scheduled_live_extract_spec.cr @@ -22,7 +22,6 @@ Spectator.describe Invidious::Hashtag do expect(info["likes"].as_i).to eq(2_283) expect(info["genre"].as_s).to eq("Gaming") - expect(info["genreUrl"].raw).to be_nil expect(info["genreUcid"].as_s).to be_empty expect(info["license"].as_s).to be_empty @@ -81,7 +80,6 @@ Spectator.describe Invidious::Hashtag do expect(info["likes"].as_i).to eq(22) expect(info["genre"].as_s).to eq("Entertainment") - expect(info["genreUrl"].raw).to be_nil expect(info["genreUcid"].as_s).to be_empty expect(info["license"].as_s).to be_empty diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 45a44c29..6211bcd7 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1,3 +1,9 @@ +enum VideoType + Video + Livestream + Scheduled +end + struct Video include DB::Serializable @@ -27,7 +33,7 @@ struct Video def to_json(locale : String?, json : JSON::Builder) json.object do - json.field "type", "video" + json.field "type", self.video_type json.field "title", self.title json.field "videoId", self.id @@ -253,61 +259,22 @@ struct Video to_json(nil, json) end - def title - info["videoDetails"]["title"]?.try &.as_s || "" - end - - def ucid - info["videoDetails"]["channelId"]?.try &.as_s || "" - end - - def author - info["videoDetails"]["author"]?.try &.as_s || "" - end - - def length_seconds : Int32 - info.dig?("microformat", "playerMicroformatRenderer", "lengthSeconds").try &.as_s.to_i || - info["videoDetails"]["lengthSeconds"]?.try &.as_s.to_i || 0 - end - - def views : Int64 - info["videoDetails"]["viewCount"]?.try &.as_s.to_i64 || 0_i64 - end - - def likes : Int64 - info["likes"]?.try &.as_i64 || 0_i64 - end - - def dislikes : Int64 - info["dislikes"]?.try &.as_i64 || 0_i64 + def video_type : VideoType + video_type = info["videoType"]?.try &.as_s || "video" + return VideoType.parse?(video_type) || VideoType::Video end def published : Time - info - .dig?("microformat", "playerMicroformatRenderer", "publishDate") + return info["published"]? .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc end def published=(other : Time) - info["microformat"].as_h["playerMicroformatRenderer"].as_h["publishDate"] = JSON::Any.new(other.to_s("%Y-%m-%d")) - end - - def allow_ratings - r = info["videoDetails"]["allowRatings"]?.try &.as_bool - r.nil? ? false : r + info["published"] = JSON::Any.new(other.to_s("%Y-%m-%d")) end def live_now - info["microformat"]?.try &.["playerMicroformatRenderer"]? - .try &.["liveBroadcastDetails"]?.try &.["isLiveNow"]?.try &.as_bool || false - end - - def is_listed - info["videoDetails"]["isCrawlable"]?.try &.as_bool || false - end - - def is_upcoming - info["videoDetails"]["isUpcoming"]?.try &.as_bool || false + return (self.video_type == VideoType::Livestream) end def premiere_timestamp : Time? @@ -316,31 +283,11 @@ struct Video .try { |t| Time.parse_rfc3339(t.as_s) } end - def keywords - info["videoDetails"]["keywords"]?.try &.as_a.map &.as_s || [] of String - end - def related_videos info["relatedVideos"]?.try &.as_a.map { |h| h.as_h.transform_values &.as_s } || [] of Hash(String, String) end - def allowed_regions - info - .dig?("microformat", "playerMicroformatRenderer", "availableCountries") - .try &.as_a.map &.as_s || [] of String - end - - def author_thumbnail : String - info["authorThumbnail"]?.try &.as_s || "" - end - - def author_verified : Bool - info["authorVerified"]?.try &.as_bool || false - end - - def sub_count_text : String - info["subCountText"]?.try &.as_s || "-" - end + # Methods for parsing streaming data def fmt_stream return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream @@ -391,6 +338,8 @@ struct Video adaptive_fmts.select &.["mimeType"]?.try &.as_s.starts_with?("audio") end + # Misc. methods + def storyboards storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec") .try &.as_s.split("|") @@ -454,8 +403,7 @@ struct Video end def paid - reason = info.dig?("playabilityStatus", "reason").try &.as_s || "" - return reason.includes? "requires payment" + return (self.reason || "").includes? "requires payment" end def premium @@ -470,29 +418,6 @@ struct Video return @captions end - def description - description = info - .dig?("microformat", "playerMicroformatRenderer", "description", "simpleText") - .try &.as_s || "" - end - - # TODO - def description=(value : String) - @description = value - end - - def description_html - info["descriptionHtml"]?.try &.as_s || "

    " - end - - def description_html=(value : String) - info["descriptionHtml"] = JSON::Any.new(value) - end - - def short_description - info["shortDescription"]?.try &.as_s? || "" - end - def hls_manifest_url : String? info.dig?("streamingData", "hlsManifestUrl").try &.as_s end @@ -501,25 +426,12 @@ struct Video info.dig?("streamingData", "dashManifestUrl").try &.as_s end - def genre : String - info["genre"]?.try &.as_s || "" - end - def genre_url : String? info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : nil end - def license : String? - info["license"]?.try &.as_s - end - - def is_family_friendly : Bool - info.dig?("microformat", "playerMicroformatRenderer", "isFamilySafe").try &.as_bool || false - end - def is_vr : Bool? - projection_type = info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s - return {"EQUIRECTANGULAR", "MESH"}.includes? projection_type + return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type end def projection_type : String? @@ -529,6 +441,91 @@ struct Video def reason : String? info["reason"]?.try &.as_s end + + # Macros defining getters/setters for various types of data + + private macro getset_string(name) + # Return {{name.stringify}} from `info` + def {{name.id.underscore}} : String + return info[{{name.stringify}}]?.try &.as_s || "" + end + + # Update {{name.stringify}} into `info` + def {{name.id.underscore}}=(value : String) + info[{{name.stringify}}] = JSON::Any.new(value) + end + + {% if flag?(:debug_macros) %} {{debug}} {% end %} + end + + private macro getset_string_array(name) + # Return {{name.stringify}} from `info` + def {{name.id.underscore}} : Array(String) + return info[{{name.stringify}}]?.try &.as_a.map &.as_s || [] of String + end + + # Update {{name.stringify}} into `info` + def {{name.id.underscore}}=(value : Array(String)) + info[{{name.stringify}}] = JSON::Any.new(value) + end + + {% if flag?(:debug_macros) %} {{debug}} {% end %} + end + + {% for op, type in {i32: Int32, i64: Int64} %} + private macro getset_{{op}}(name) + def \{{name.id.underscore}} : {{type}} + return info[\{{name.stringify}}]?.try &.as_i.to_{{op}} || 0_{{op}} + end + + def \{{name.id.underscore}}=(value : Int) + info[\{{name.stringify}}] = JSON::Any.new(value.to_i64) + end + + \{% if flag?(:debug_macros) %} \{{debug}} \{% end %} + end + {% end %} + + private macro getset_bool(name) + # Return {{name.stringify}} from `info` + def {{name.id.underscore}} : Bool + return info[{{name.stringify}}]?.try &.as_bool || false + end + + # Update {{name.stringify}} into `info` + def {{name.id.underscore}}=(value : Bool) + info[{{name.stringify}}] = JSON::Any.new(value) + end + + {% if flag?(:debug_macros) %} {{debug}} {% end %} + end + + # Method definitions, using the macros above + + getset_string author + getset_string authorThumbnail + getset_string description + getset_string descriptionHtml + getset_string genre + getset_string genreUcid + getset_string license + getset_string shortDescription + getset_string subCountText + getset_string title + getset_string ucid + + getset_string_array allowedRegions + getset_string_array keywords + + getset_i32 lengthSeconds + getset_i64 likes + getset_i64 views + + getset_bool allowRatings + getset_bool authorVerified + getset_bool isFamilyFriendly + getset_bool isListed + getset_bool isUpcoming end class VideoRedirect < Exception @@ -684,6 +681,42 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer + video_details = player_response.dig?("videoDetails") + microformat = player_response.dig?("microformat", "playerMicroformatRenderer") + + raise BrokenTubeException.new("videoDetails") if !video_details + raise BrokenTubeException.new("microformat") if !microformat + + # Basic video infos + + title = video_details["title"]?.try &.as_s + views = video_details["viewCount"]?.try &.as_s.to_i64 + + length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) + .try &.as_s.to_i64 + + published = microformat["publishDate"]? + .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc + + premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") + .try { |t| Time.parse_rfc3339(t.as_s) } + + live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") + .try &.as_bool || false + + # Extra video infos + + allowed_regions = microformat["availableCountries"]? + .try &.as_a.map &.as_s || [] of String + + allow_ratings = video_details["allowRatings"]?.try &.as_bool + family_friendly = microformat["isFamilySafe"].try &.as_bool + is_listed = video_details["isCrawlable"]?.try &.as_bool + is_upcoming = video_details["isUpcoming"]?.try &.as_bool + + keywords = video_details["keywords"]? + .try &.as_a.map &.as_s || [] of String + # Related videos LOGGER.debug("extract_video_info: parsing related videos...") @@ -738,6 +771,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # Description + description = microformat.dig?("description", "simpleText").try &.as_s || "" short_description = player_response.dig?("videoDetails", "shortDescription") description_html = video_secondary_renderer.try &.dig?("description", "runs") @@ -749,7 +783,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") .try &.as_a - genre = player_response.dig?("microformat", "playerMicroformatRenderer", "category") + genre = microformat["category"]? genre_ucid = nil license = nil @@ -771,6 +805,9 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # Author infos + author = video_details["author"]?.try &.as_s + ucid = video_details["channelId"]?.try &.as_s + if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url") author_verified = has_verified_badge?(author_info["badges"]?) @@ -782,19 +819,46 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # Return data + if live_now + video_type = VideoType::Livestream + elsif premiere_timestamp.not_nil! + video_type = VideoType::Scheduled + published = premiere_timestamp || Time.utc + else + video_type = VideoType::Video + end + params = { - "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), - "relatedVideos" => JSON::Any.new(related), - "likes" => JSON::Any.new(likes || 0_i64), - "dislikes" => JSON::Any.new(0_i64), + "videoType" => JSON::Any.new(video_type.to_s), + # Basic video infos + "title" => JSON::Any.new(title || ""), + "views" => JSON::Any.new(views || 0_i64), + "likes" => JSON::Any.new(likes || 0_i64), + "lengthSeconds" => JSON::Any.new(length_txt || 0_i64), + "published" => JSON::Any.new(published.to_rfc3339), + # Extra video infos + "allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }), + "allowRatings" => JSON::Any.new(allow_ratings || false), + "isFamilyFriendly" => JSON::Any.new(family_friendly || false), + "isListed" => JSON::Any.new(is_listed || false), + "isUpcoming" => JSON::Any.new(is_upcoming || false), + "keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }), + # Related videos + "relatedVideos" => JSON::Any.new(related), + # Description + "description" => JSON::Any.new(description || ""), "descriptionHtml" => JSON::Any.new(description_html || "

    "), - "genre" => JSON::Any.new(genre.try &.as_s || ""), - "genreUrl" => JSON::Any.new(nil), - "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), - "license" => JSON::Any.new(license.try &.as_s || ""), - "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), - "authorVerified" => JSON::Any.new(author_verified), - "subCountText" => JSON::Any.new(subs_text || "-"), + "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), + # Video metadata + "genre" => JSON::Any.new(genre.try &.as_s || ""), + "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), + "license" => JSON::Any.new(license.try &.as_s || ""), + # Author infos + "author" => JSON::Any.new(author || ""), + "ucid" => JSON::Any.new(ucid || ""), + "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), + "authorVerified" => JSON::Any.new(author_verified), + "subCountText" => JSON::Any.new(subs_text || "-"), } return params -- cgit v1.2.3 From 7df0cfcbed6100f16b1ccc2bd93aba42cff2b669 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 3 Aug 2022 21:31:46 +0200 Subject: Videos: fix 'views' parsing for livestreams --- src/invidious/videos.cr | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 6211bcd7..b76da8f9 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -690,7 +690,11 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # Basic video infos title = video_details["title"]?.try &.as_s - views = video_details["viewCount"]?.try &.as_s.to_i64 + + views = video_primary_renderer + .dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text") + .try &.as_s.to_i64 + views ||= video_details["viewCount"]?.try &.as_s.to_i64 length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) .try &.as_s.to_i64 -- cgit v1.2.3 From e23ceb6ae92b685152a284f840fa9aee0f1853ab Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 21 Aug 2022 17:28:27 +0200 Subject: videos: Fix extraction code according to tests --- src/invidious/videos.cr | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index b76da8f9..a01a18b7 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -666,20 +666,20 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results - primary_results = main_results.dig?("results", "results", "contents") + # Primary results are not available on Music videos + # See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725 + if primary_results = main_results.dig?("results", "results", "contents") + video_primary_renderer = primary_results + .as_a.find(&.["videoPrimaryInfoRenderer"]?) + .try &.["videoPrimaryInfoRenderer"] - raise BrokenTubeException.new("results") if !primary_results + video_secondary_renderer = primary_results + .as_a.find(&.["videoSecondaryInfoRenderer"]?) + .try &.["videoSecondaryInfoRenderer"] - video_primary_renderer = primary_results - .as_a.find(&.["videoPrimaryInfoRenderer"]?) - .try &.["videoPrimaryInfoRenderer"] - - video_secondary_renderer = primary_results - .as_a.find(&.["videoSecondaryInfoRenderer"]?) - .try &.["videoSecondaryInfoRenderer"] - - raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer - raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer + raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer + raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer + end video_details = player_response.dig?("videoDetails") microformat = player_response.dig?("microformat", "playerMicroformatRenderer") @@ -691,9 +691,12 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any title = video_details["title"]?.try &.as_s + # We have to try to extract viewCount from videoPrimaryInfoRenderer first, + # then from videoDetails, as the latter is "0" for livestreams (we want + # to get the amount of viewers watching). views = video_primary_renderer - .dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text") - .try &.as_s.to_i64 + .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text") + .try &.as_s.to_i64 views ||= video_details["viewCount"]?.try &.as_s.to_i64 length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) @@ -825,7 +828,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any if live_now video_type = VideoType::Livestream - elsif premiere_timestamp.not_nil! + elsif !premiere_timestamp.nil? video_type = VideoType::Scheduled published = premiere_timestamp || Time.utc else @@ -861,7 +864,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any "author" => JSON::Any.new(author || ""), "ucid" => JSON::Any.new(ucid || ""), "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), - "authorVerified" => JSON::Any.new(author_verified), + "authorVerified" => JSON::Any.new(author_verified || false), "subCountText" => JSON::Any.new(subs_text || "-"), } -- cgit v1.2.3 From ae03ed7bf7faeefa3d8e8bf5b6382f56ef154fe8 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 23 Aug 2022 17:56:09 +0200 Subject: videos: move player/next parsing code to a dedicated file --- spec/parsers_helper.cr | 1 + src/invidious/videos.cr | 336 ---------------------------------------- src/invidious/videos/parser.cr | 337 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 336 deletions(-) create mode 100644 src/invidious/videos/parser.cr (limited to 'src') diff --git a/spec/parsers_helper.cr b/spec/parsers_helper.cr index e9154875..bf05f9ec 100644 --- a/spec/parsers_helper.cr +++ b/spec/parsers_helper.cr @@ -12,6 +12,7 @@ require "../src/invidious/helpers/logger" require "../src/invidious/helpers/utils" require "../src/invidious/videos" +require "../src/invidious/videos/*" require "../src/invidious/comments" require "../src/invidious/helpers/serialized_yt_data" diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index a01a18b7..9b19bc2a 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -535,342 +535,6 @@ class VideoRedirect < Exception end end -# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer". -# The former is preferred as it has more videos in it. The second has -# the same 11 first entries as the compact rendered. -# -# TODO: "compactRadioRenderer" (Mix) and -# TODO: Use a proper struct/class instead of a hacky JSON object -def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? - return nil if !related["videoId"]? - - # The compact renderer has video length in seconds, where the end - # screen rendered has a full text version ("42:40") - length = related["lengthInSeconds"]?.try &.as_i.to_s - length ||= related.dig?("lengthText", "simpleText").try do |box| - decode_length_seconds(box.as_s).to_s - end - - # Both have "short", so the "long" option shouldn't be required - channel_info = (related["shortBylineText"]? || related["longBylineText"]?) - .try &.dig?("runs", 0) - - author = channel_info.try &.dig?("text") - author_verified = has_verified_badge?(related["ownerBadges"]?).to_s - - ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } - - # "4,088,033 views", only available on compact renderer - # and when video is not a livestream - view_count = related.dig?("viewCountText", "simpleText") - .try &.as_s.gsub(/\D/, "") - - short_view_count = related.try do |r| - HelperExtractors.get_short_view_count(r).to_s - end - - LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") - - # TODO: when refactoring video types, make a struct for related videos - # or reuse an existing type, if that fits. - return { - "id" => related["videoId"], - "title" => related["title"]["simpleText"], - "author" => author || JSON::Any.new(""), - "ucid" => JSON::Any.new(ucid || ""), - "length_seconds" => JSON::Any.new(length || "0"), - "view_count" => JSON::Any.new(view_count || "0"), - "short_view_count" => JSON::Any.new(short_view_count || "0"), - "author_verified" => JSON::Any.new(author_verified), - } -end - -def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) - # Init client config for the API - client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) - if context_screen == "embed" - client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed - end - - # Fetch data from the player endpoint - player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) - - playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s - - if playability_status != "OK" - subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason") - reason = subreason.try &.[]?("simpleText").try &.as_s - reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") - reason ||= player_response.dig("playabilityStatus", "reason").as_s - - # Stop here if video is not a scheduled livestream - if playability_status != "LIVE_STREAM_OFFLINE" - return { - "reason" => JSON::Any.new(reason), - } - end - elsif video_id != player_response.dig("videoDetails", "videoId") - # YouTube may return a different video player response than expected. - # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 - raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)") - else - reason = nil - end - - # Don't fetch the next endpoint if the video is unavailable. - if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status) - next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) - player_response = player_response.merge(next_response) - end - - params = parse_video_info(video_id, player_response) - params["reason"] = JSON::Any.new(reason) if reason - - # Fetch the video streams using an Android client in order to get the decrypted URLs and - # maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs: - # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 - if reason.nil? - if context_screen == "embed" - client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed - else - client_config.client_type = YoutubeAPI::ClientType::Android - end - android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) - - # Sometimes, the video is available from the web client, but not on Android, so check - # that here, and fallback to the streaming data from the web client if needed. - # See: https://github.com/iv-org/invidious/issues/2549 - if video_id != android_player.dig("videoDetails", "videoId") - # YouTube may return a different video player response than expected. - # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 - raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (ANDROID client)") - elsif android_player["playabilityStatus"]["status"] == "OK" - params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("") - else - params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("") - end - end - - # TODO: clean that up - {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| - params[f] = player_response[f] if player_response[f]? - end - - return params -end - -def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any) - # Top level elements - - main_results = player_response.dig?("contents", "twoColumnWatchNextResults") - - raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results - - # Primary results are not available on Music videos - # See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725 - if primary_results = main_results.dig?("results", "results", "contents") - video_primary_renderer = primary_results - .as_a.find(&.["videoPrimaryInfoRenderer"]?) - .try &.["videoPrimaryInfoRenderer"] - - video_secondary_renderer = primary_results - .as_a.find(&.["videoSecondaryInfoRenderer"]?) - .try &.["videoSecondaryInfoRenderer"] - - raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer - raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer - end - - video_details = player_response.dig?("videoDetails") - microformat = player_response.dig?("microformat", "playerMicroformatRenderer") - - raise BrokenTubeException.new("videoDetails") if !video_details - raise BrokenTubeException.new("microformat") if !microformat - - # Basic video infos - - title = video_details["title"]?.try &.as_s - - # We have to try to extract viewCount from videoPrimaryInfoRenderer first, - # then from videoDetails, as the latter is "0" for livestreams (we want - # to get the amount of viewers watching). - views = video_primary_renderer - .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text") - .try &.as_s.to_i64 - views ||= video_details["viewCount"]?.try &.as_s.to_i64 - - length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) - .try &.as_s.to_i64 - - published = microformat["publishDate"]? - .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc - - premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") - .try { |t| Time.parse_rfc3339(t.as_s) } - - live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") - .try &.as_bool || false - - # Extra video infos - - allowed_regions = microformat["availableCountries"]? - .try &.as_a.map &.as_s || [] of String - - allow_ratings = video_details["allowRatings"]?.try &.as_bool - family_friendly = microformat["isFamilySafe"].try &.as_bool - is_listed = video_details["isCrawlable"]?.try &.as_bool - is_upcoming = video_details["isUpcoming"]?.try &.as_bool - - keywords = video_details["keywords"]? - .try &.as_a.map &.as_s || [] of String - - # Related videos - - LOGGER.debug("extract_video_info: parsing related videos...") - - related = [] of JSON::Any - - # Parse "compactVideoRenderer" items (under secondary results) - secondary_results = main_results - .dig?("secondaryResults", "secondaryResults", "results") - secondary_results.try &.as_a.each do |element| - if item = element["compactVideoRenderer"]? - related_video = parse_related_video(item) - related << JSON::Any.new(related_video) if related_video - end - end - - # If nothing was found previously, fall back to end screen renderer - if related.empty? - # Container for "endScreenVideoRenderer" items - player_overlays = player_response.dig?( - "playerOverlays", "playerOverlayRenderer", - "endScreen", "watchNextEndScreenRenderer", "results" - ) - - player_overlays.try &.as_a.each do |element| - if item = element["endScreenVideoRenderer"]? - related_video = parse_related_video(item) - related << JSON::Any.new(related_video) if related_video - end - end - end - - # Likes - - toplevel_buttons = video_primary_renderer - .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") - - if toplevel_buttons - likes_button = toplevel_buttons.as_a - .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE") - .try &.["toggleButtonRenderer"] - - if likes_button - likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?) - .try &.dig?("accessibility", "accessibilityData", "label") - likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt - - LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") - LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes - end - end - - # Description - - description = microformat.dig?("description", "simpleText").try &.as_s || "" - short_description = player_response.dig?("videoDetails", "shortDescription") - - description_html = video_secondary_renderer.try &.dig?("description", "runs") - .try &.as_a.try { |t| content_to_comment_html(t, video_id) } - - # Video metadata - - metadata = video_secondary_renderer - .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") - .try &.as_a - - genre = microformat["category"]? - genre_ucid = nil - license = nil - - metadata.try &.each do |row| - metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s - contents = row.dig?("metadataRowRenderer", "contents", 0) - - if metadata_title == "Category" - contents = contents.try &.dig?("runs", 0) - - genre = contents.try &.["text"]? - genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId") - elsif metadata_title == "License" - license = contents.try &.dig?("runs", 0, "text") - elsif metadata_title == "Licensed to YouTube by" - license = contents.try &.["simpleText"]? - end - end - - # Author infos - - author = video_details["author"]?.try &.as_s - ucid = video_details["channelId"]?.try &.as_s - - if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") - author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url") - author_verified = has_verified_badge?(author_info["badges"]?) - - subs_text = author_info["subscriberCountText"]? - .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } - .try &.as_s.split(" ", 2)[0] - end - - # Return data - - if live_now - video_type = VideoType::Livestream - elsif !premiere_timestamp.nil? - video_type = VideoType::Scheduled - published = premiere_timestamp || Time.utc - else - video_type = VideoType::Video - end - - params = { - "videoType" => JSON::Any.new(video_type.to_s), - # Basic video infos - "title" => JSON::Any.new(title || ""), - "views" => JSON::Any.new(views || 0_i64), - "likes" => JSON::Any.new(likes || 0_i64), - "lengthSeconds" => JSON::Any.new(length_txt || 0_i64), - "published" => JSON::Any.new(published.to_rfc3339), - # Extra video infos - "allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }), - "allowRatings" => JSON::Any.new(allow_ratings || false), - "isFamilyFriendly" => JSON::Any.new(family_friendly || false), - "isListed" => JSON::Any.new(is_listed || false), - "isUpcoming" => JSON::Any.new(is_upcoming || false), - "keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }), - # Related videos - "relatedVideos" => JSON::Any.new(related), - # Description - "description" => JSON::Any.new(description || ""), - "descriptionHtml" => JSON::Any.new(description_html || "

    "), - "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), - # Video metadata - "genre" => JSON::Any.new(genre.try &.as_s || ""), - "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), - "license" => JSON::Any.new(license.try &.as_s || ""), - # Author infos - "author" => JSON::Any.new(author || ""), - "ucid" => JSON::Any.new(ucid || ""), - "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), - "authorVerified" => JSON::Any.new(author_verified || false), - "subCountText" => JSON::Any.new(subs_text || "-"), - } - - return params -end - def get_video(id, refresh = true, region = nil, force_refresh = false) if (video = Invidious::Database::Videos.select(id)) && !region # If record was last updated over 10 minutes ago, or video has since premiered, diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr new file mode 100644 index 00000000..ff5d15de --- /dev/null +++ b/src/invidious/videos/parser.cr @@ -0,0 +1,337 @@ +require "json" + +# Use to parse both "compactVideoRenderer" and "endScreenVideoRenderer". +# The former is preferred as it has more videos in it. The second has +# the same 11 first entries as the compact rendered. +# +# TODO: "compactRadioRenderer" (Mix) and +# TODO: Use a proper struct/class instead of a hacky JSON object +def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? + return nil if !related["videoId"]? + + # The compact renderer has video length in seconds, where the end + # screen rendered has a full text version ("42:40") + length = related["lengthInSeconds"]?.try &.as_i.to_s + length ||= related.dig?("lengthText", "simpleText").try do |box| + decode_length_seconds(box.as_s).to_s + end + + # Both have "short", so the "long" option shouldn't be required + channel_info = (related["shortBylineText"]? || related["longBylineText"]?) + .try &.dig?("runs", 0) + + author = channel_info.try &.dig?("text") + author_verified = has_verified_badge?(related["ownerBadges"]?).to_s + + ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } + + # "4,088,033 views", only available on compact renderer + # and when video is not a livestream + view_count = related.dig?("viewCountText", "simpleText") + .try &.as_s.gsub(/\D/, "") + + short_view_count = related.try do |r| + HelperExtractors.get_short_view_count(r).to_s + end + + LOGGER.trace("parse_related_video: Found \"watchNextEndScreenRenderer\" container") + + # TODO: when refactoring video types, make a struct for related videos + # or reuse an existing type, if that fits. + return { + "id" => related["videoId"], + "title" => related["title"]["simpleText"], + "author" => author || JSON::Any.new(""), + "ucid" => JSON::Any.new(ucid || ""), + "length_seconds" => JSON::Any.new(length || "0"), + "view_count" => JSON::Any.new(view_count || "0"), + "short_view_count" => JSON::Any.new(short_view_count || "0"), + "author_verified" => JSON::Any.new(author_verified), + } +end + +def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) + # Init client config for the API + client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) + if context_screen == "embed" + client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed + end + + # Fetch data from the player endpoint + player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) + + playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s + + if playability_status != "OK" + subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason") + reason = subreason.try &.[]?("simpleText").try &.as_s + reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") + reason ||= player_response.dig("playabilityStatus", "reason").as_s + + # Stop here if video is not a scheduled livestream + if playability_status != "LIVE_STREAM_OFFLINE" + return { + "reason" => JSON::Any.new(reason), + } + end + elsif video_id != player_response.dig("videoDetails", "videoId") + # YouTube may return a different video player response than expected. + # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 + raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)") + else + reason = nil + end + + # Don't fetch the next endpoint if the video is unavailable. + if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status) + next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) + player_response = player_response.merge(next_response) + end + + params = parse_video_info(video_id, player_response) + params["reason"] = JSON::Any.new(reason) if reason + + # Fetch the video streams using an Android client in order to get the decrypted URLs and + # maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs: + # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 + if reason.nil? + if context_screen == "embed" + client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed + else + client_config.client_type = YoutubeAPI::ClientType::Android + end + android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) + + # Sometimes, the video is available from the web client, but not on Android, so check + # that here, and fallback to the streaming data from the web client if needed. + # See: https://github.com/iv-org/invidious/issues/2549 + if video_id != android_player.dig("videoDetails", "videoId") + # YouTube may return a different video player response than expected. + # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 + raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (ANDROID client)") + elsif android_player["playabilityStatus"]["status"] == "OK" + params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("") + else + params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("") + end + end + + # TODO: clean that up + {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| + params[f] = player_response[f] if player_response[f]? + end + + return params +end + +def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any) + # Top level elements + + main_results = player_response.dig?("contents", "twoColumnWatchNextResults") + + raise BrokenTubeException.new("twoColumnWatchNextResults") if !main_results + + # Primary results are not available on Music videos + # See: https://github.com/iv-org/invidious/pull/3238#issuecomment-1207193725 + if primary_results = main_results.dig?("results", "results", "contents") + video_primary_renderer = primary_results + .as_a.find(&.["videoPrimaryInfoRenderer"]?) + .try &.["videoPrimaryInfoRenderer"] + + video_secondary_renderer = primary_results + .as_a.find(&.["videoSecondaryInfoRenderer"]?) + .try &.["videoSecondaryInfoRenderer"] + + raise BrokenTubeException.new("videoPrimaryInfoRenderer") if !video_primary_renderer + raise BrokenTubeException.new("videoSecondaryInfoRenderer") if !video_secondary_renderer + end + + video_details = player_response.dig?("videoDetails") + microformat = player_response.dig?("microformat", "playerMicroformatRenderer") + + raise BrokenTubeException.new("videoDetails") if !video_details + raise BrokenTubeException.new("microformat") if !microformat + + # Basic video infos + + title = video_details["title"]?.try &.as_s + + # We have to try to extract viewCount from videoPrimaryInfoRenderer first, + # then from videoDetails, as the latter is "0" for livestreams (we want + # to get the amount of viewers watching). + views = video_primary_renderer + .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text") + .try &.as_s.to_i64 + views ||= video_details["viewCount"]?.try &.as_s.to_i64 + + length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) + .try &.as_s.to_i64 + + published = microformat["publishDate"]? + .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc + + premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") + .try { |t| Time.parse_rfc3339(t.as_s) } + + live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") + .try &.as_bool || false + + # Extra video infos + + allowed_regions = microformat["availableCountries"]? + .try &.as_a.map &.as_s || [] of String + + allow_ratings = video_details["allowRatings"]?.try &.as_bool + family_friendly = microformat["isFamilySafe"].try &.as_bool + is_listed = video_details["isCrawlable"]?.try &.as_bool + is_upcoming = video_details["isUpcoming"]?.try &.as_bool + + keywords = video_details["keywords"]? + .try &.as_a.map &.as_s || [] of String + + # Related videos + + LOGGER.debug("extract_video_info: parsing related videos...") + + related = [] of JSON::Any + + # Parse "compactVideoRenderer" items (under secondary results) + secondary_results = main_results + .dig?("secondaryResults", "secondaryResults", "results") + secondary_results.try &.as_a.each do |element| + if item = element["compactVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + + # If nothing was found previously, fall back to end screen renderer + if related.empty? + # Container for "endScreenVideoRenderer" items + player_overlays = player_response.dig?( + "playerOverlays", "playerOverlayRenderer", + "endScreen", "watchNextEndScreenRenderer", "results" + ) + + player_overlays.try &.as_a.each do |element| + if item = element["endScreenVideoRenderer"]? + related_video = parse_related_video(item) + related << JSON::Any.new(related_video) if related_video + end + end + end + + # Likes + + toplevel_buttons = video_primary_renderer + .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") + + if toplevel_buttons + likes_button = toplevel_buttons.as_a + .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE") + .try &.["toggleButtonRenderer"] + + if likes_button + likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?) + .try &.dig?("accessibility", "accessibilityData", "label") + likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt + + LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") + LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes + end + end + + # Description + + description = microformat.dig?("description", "simpleText").try &.as_s || "" + short_description = player_response.dig?("videoDetails", "shortDescription") + + description_html = video_secondary_renderer.try &.dig?("description", "runs") + .try &.as_a.try { |t| content_to_comment_html(t, video_id) } + + # Video metadata + + metadata = video_secondary_renderer + .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") + .try &.as_a + + genre = microformat["category"]? + genre_ucid = nil + license = nil + + metadata.try &.each do |row| + metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s + contents = row.dig?("metadataRowRenderer", "contents", 0) + + if metadata_title == "Category" + contents = contents.try &.dig?("runs", 0) + + genre = contents.try &.["text"]? + genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId") + elsif metadata_title == "License" + license = contents.try &.dig?("runs", 0, "text") + elsif metadata_title == "Licensed to YouTube by" + license = contents.try &.["simpleText"]? + end + end + + # Author infos + + author = video_details["author"]?.try &.as_s + ucid = video_details["channelId"]?.try &.as_s + + if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") + author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url") + author_verified = has_verified_badge?(author_info["badges"]?) + + subs_text = author_info["subscriberCountText"]? + .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } + .try &.as_s.split(" ", 2)[0] + end + + # Return data + + if live_now + video_type = VideoType::Livestream + elsif !premiere_timestamp.nil? + video_type = VideoType::Scheduled + published = premiere_timestamp || Time.utc + else + video_type = VideoType::Video + end + + params = { + "videoType" => JSON::Any.new(video_type.to_s), + # Basic video infos + "title" => JSON::Any.new(title || ""), + "views" => JSON::Any.new(views || 0_i64), + "likes" => JSON::Any.new(likes || 0_i64), + "lengthSeconds" => JSON::Any.new(length_txt || 0_i64), + "published" => JSON::Any.new(published.to_rfc3339), + # Extra video infos + "allowedRegions" => JSON::Any.new(allowed_regions.map { |v| JSON::Any.new(v) }), + "allowRatings" => JSON::Any.new(allow_ratings || false), + "isFamilyFriendly" => JSON::Any.new(family_friendly || false), + "isListed" => JSON::Any.new(is_listed || false), + "isUpcoming" => JSON::Any.new(is_upcoming || false), + "keywords" => JSON::Any.new(keywords.map { |v| JSON::Any.new(v) }), + # Related videos + "relatedVideos" => JSON::Any.new(related), + # Description + "description" => JSON::Any.new(description || ""), + "descriptionHtml" => JSON::Any.new(description_html || "

    "), + "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), + # Video metadata + "genre" => JSON::Any.new(genre.try &.as_s || ""), + "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), + "license" => JSON::Any.new(license.try &.as_s || ""), + # Author infos + "author" => JSON::Any.new(author || ""), + "ucid" => JSON::Any.new(ucid || ""), + "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), + "authorVerified" => JSON::Any.new(author_verified || false), + "subCountText" => JSON::Any.new(subs_text || "-"), + } + + return params +end -- cgit v1.2.3 From 87a5d70062b8f4b2b942d027f8c4cf0bb30907eb Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 23 Aug 2022 19:03:09 +0200 Subject: videos: move API's JSON structure to a dedicated module --- src/invidious.cr | 2 + src/invidious/channels/channels.cr | 2 +- src/invidious/channels/community.cr | 2 +- src/invidious/helpers/serialized_yt_data.cr | 4 +- src/invidious/jsonify/api_v1/video_json.cr | 255 +++++++++++++++++++++++++++ src/invidious/playlists.cr | 2 +- src/invidious/routes/api/v1/misc.cr | 2 +- src/invidious/routes/api/v1/videos.cr | 2 +- src/invidious/videos.cr | 256 +--------------------------- 9 files changed, 272 insertions(+), 255 deletions(-) create mode 100644 src/invidious/jsonify/api_v1/video_json.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 8df0c0cd..2874cc71 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -39,6 +39,8 @@ require "./invidious/yt_backend/*" require "./invidious/frontend/*" require "./invidious/videos/*" +require "./invidious/jsonify/**" + require "./invidious/*" require "./invidious/channels/*" require "./invidious/user/*" diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index e0459cc3..e3d3d9ee 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -29,7 +29,7 @@ struct ChannelVideo json.field "title", self.title json.field "videoId", self.id json.field "videoThumbnails" do - generate_thumbnails(json, self.id) + Invidious::JSONify::APIv1.thumbnails(json, self.id) end json.field "lengthSeconds", self.length_seconds diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 2a2c74aa..8e300288 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -138,7 +138,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) json.field "title", video_title json.field "videoId", video_id json.field "videoThumbnails" do - generate_thumbnails(json, video_id) + Invidious::JSONify::APIv1.thumbnails(json, video_id) end json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 3918bd13..c52e2a0d 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -76,7 +76,7 @@ struct SearchVideo json.field "authorUrl", "/channel/#{self.ucid}" json.field "videoThumbnails" do - generate_thumbnails(json, self.id) + Invidious::JSONify::APIv1.thumbnails(json, self.id) end json.field "description", html_to_content(self.description_html) @@ -155,7 +155,7 @@ struct SearchPlaylist json.field "lengthSeconds", video.length_seconds json.field "videoThumbnails" do - generate_thumbnails(json, video.id) + Invidious::JSONify::APIv1.thumbnails(json, video.id) end end end diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr new file mode 100644 index 00000000..1082f6d3 --- /dev/null +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -0,0 +1,255 @@ +require "json" + +module Invidious::JSONify::APIv1 + extend self + + def video(video : Video, json : JSON::Builder, *, locale : String?) + json.object do + json.field "type", video.video_type + + json.field "title", video.title + json.field "videoId", video.id + + json.field "error", video.info["reason"] if video.info["reason"]? + + json.field "videoThumbnails" do + self.thumbnails(json, video.id) + end + json.field "storyboards" do + self.storyboards(json, video.id, video.storyboards) + end + + json.field "description", video.description + json.field "descriptionHtml", video.description_html + json.field "published", video.published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(video.published, locale)) + json.field "keywords", video.keywords + + json.field "viewCount", video.views + json.field "likeCount", video.likes + json.field "dislikeCount", 0_i64 + + json.field "paid", video.paid + json.field "premium", video.premium + json.field "isFamilyFriendly", video.is_family_friendly + json.field "allowedRegions", video.allowed_regions + json.field "genre", video.genre + json.field "genreUrl", video.genre_url + + json.field "author", video.author + json.field "authorId", video.ucid + json.field "authorUrl", "/channel/#{video.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", video.author_thumbnail.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", quality + end + end + end + end + + json.field "subCountText", video.sub_count_text + + json.field "lengthSeconds", video.length_seconds + json.field "allowRatings", video.allow_ratings + json.field "rating", 0_i64 + json.field "isListed", video.is_listed + json.field "liveNow", video.live_now + json.field "isUpcoming", video.is_upcoming + + if video.premiere_timestamp + json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix + end + + if hlsvp = video.hls_manifest_url + hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL) + json.field "hlsUrl", hlsvp + end + + json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{video.id}" + + json.field "adaptiveFormats" do + json.array do + video.adaptive_fmts.each do |fmt| + json.object do + # Only available on regular videos, not livestreams/OTF streams + if init_range = fmt["initRange"]? + json.field "init", "#{init_range["start"]}-#{init_range["end"]}" + end + if index_range = fmt["indexRange"]? + json.field "index", "#{index_range["start"]}-#{index_range["end"]}" + end + + # Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only) + json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? + + json.field "url", fmt["url"] + json.field "itag", fmt["itag"].as_i.to_s + json.field "type", fmt["mimeType"] + json.field "clen", fmt["contentLength"]? || "-1" + json.field "lmt", fmt["lastModified"] + json.field "projectionType", fmt["projectionType"] + + if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) + fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 + json.field "fps", fps + json.field "container", fmt_info["ext"] + json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] + + if fmt_info["height"]? + json.field "resolution", "#{fmt_info["height"]}p" + + quality_label = "#{fmt_info["height"]}p" + if fps > 30 + quality_label += "60" + end + json.field "qualityLabel", quality_label + + if fmt_info["width"]? + json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" + end + end + end + + # Livestream chunk infos + json.field "targetDurationSec", fmt["targetDurationSec"].as_i if fmt.has_key?("targetDurationSec") + json.field "maxDvrDurationSec", fmt["maxDvrDurationSec"].as_i if fmt.has_key?("maxDvrDurationSec") + + # Audio-related data + json.field "audioQuality", fmt["audioQuality"] if fmt.has_key?("audioQuality") + json.field "audioSampleRate", fmt["audioSampleRate"].as_s.to_i if fmt.has_key?("audioSampleRate") + json.field "audioChannels", fmt["audioChannels"] if fmt.has_key?("audioChannels") + + # Extra misc stuff + json.field "colorInfo", fmt["colorInfo"] if fmt.has_key?("colorInfo") + json.field "captionTrack", fmt["captionTrack"] if fmt.has_key?("captionTrack") + end + end + end + end + + json.field "formatStreams" do + json.array do + video.fmt_stream.each do |fmt| + json.object do + json.field "url", fmt["url"] + json.field "itag", fmt["itag"].as_i.to_s + json.field "type", fmt["mimeType"] + json.field "quality", fmt["quality"] + + fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) + if fmt_info + fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 + json.field "fps", fps + json.field "container", fmt_info["ext"] + json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] + + if fmt_info["height"]? + json.field "resolution", "#{fmt_info["height"]}p" + + quality_label = "#{fmt_info["height"]}p" + if fps > 30 + quality_label += "60" + end + json.field "qualityLabel", quality_label + + if fmt_info["width"]? + json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" + end + end + end + end + end + end + end + + json.field "captions" do + json.array do + video.captions.each do |caption| + json.object do + json.field "label", caption.name + json.field "language_code", caption.language_code + json.field "url", "/api/v1/captions/#{video.id}?label=#{URI.encode_www_form(caption.name)}" + end + end + end + end + + json.field "recommendedVideos" do + json.array do + video.related_videos.each do |rv| + if rv["id"]? + json.object do + json.field "videoId", rv["id"] + json.field "title", rv["title"] + json.field "videoThumbnails" do + self.thumbnails(json, rv["id"]) + end + + json.field "author", rv["author"] + json.field "authorUrl", "/channel/#{rv["ucid"]?}" + json.field "authorId", rv["ucid"]? + if rv["author_thumbnail"]? + json.field "authorThumbnails" do + json.array do + qualities = {32, 48, 76, 100, 176, 512} + + qualities.each do |quality| + json.object do + json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") + json.field "width", quality + json.field "height", quality + end + end + end + end + end + + json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i + json.field "viewCountText", rv["short_view_count"]? + json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 + end + end + end + end + end + end + end + + def thumbnails(json, id) + json.array do + build_thumbnails(id).each do |thumbnail| + json.object do + json.field "quality", thumbnail[:name] + json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" + json.field "width", thumbnail[:width] + json.field "height", thumbnail[:height] + end + end + end + end + + def storyboards(json, id, storyboards) + json.array do + storyboards.each do |storyboard| + json.object do + json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}" + json.field "templateUrl", storyboard[:url] + json.field "width", storyboard[:width] + json.field "height", storyboard[:height] + json.field "count", storyboard[:count] + json.field "interval", storyboard[:interval] + json.field "storyboardWidth", storyboard[:storyboard_width] + json.field "storyboardHeight", storyboard[:storyboard_height] + json.field "storyboardCount", storyboard[:storyboard_count] + end + end + end + end +end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index c4eb7507..57f1f53e 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -56,7 +56,7 @@ struct PlaylistVideo json.field "authorUrl", "/channel/#{self.ucid}" json.field "videoThumbnails" do - generate_thumbnails(json, self.id) + Invidious::JSONify::APIv1.thumbnails(json, self.id) end if index diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 844fedb8..43d360e6 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -124,7 +124,7 @@ module Invidious::Routes::API::V1::Misc json.field "videoThumbnails" do json.array do - generate_thumbnails(json, video.id) + Invidious::JSONify::APIv1.thumbnails(json, video.id) end end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 1b7b4fa7..6f1f5916 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -185,7 +185,7 @@ module Invidious::Routes::API::V1::Videos response = JSON.build do |json| json.object do json.field "storyboards" do - generate_storyboards(json, id, storyboards) + Invidious::JSONify::APIv1.storyboards(json, id, storyboards) end end end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 9b19bc2a..fcc9a8a4 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -31,234 +31,25 @@ struct Video end end - def to_json(locale : String?, json : JSON::Builder) - json.object do - json.field "type", self.video_type - - json.field "title", self.title - json.field "videoId", self.id - - json.field "error", info["reason"] if info["reason"]? - - json.field "videoThumbnails" do - generate_thumbnails(json, self.id) - end - json.field "storyboards" do - generate_storyboards(json, self.id, self.storyboards) - end - - json.field "description", self.description - json.field "descriptionHtml", self.description_html - json.field "published", self.published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) - json.field "keywords", self.keywords - - json.field "viewCount", self.views - json.field "likeCount", self.likes - json.field "dislikeCount", 0_i64 - - json.field "paid", self.paid - json.field "premium", self.premium - json.field "isFamilyFriendly", self.is_family_friendly - json.field "allowedRegions", self.allowed_regions - json.field "genre", self.genre - json.field "genreUrl", self.genre_url - - 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.gsub(/=s\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - - json.field "subCountText", self.sub_count_text - - json.field "lengthSeconds", self.length_seconds - json.field "allowRatings", self.allow_ratings - json.field "rating", 0_i64 - json.field "isListed", self.is_listed - json.field "liveNow", self.live_now - json.field "isUpcoming", self.is_upcoming - - if self.premiere_timestamp - json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix - end - - if hlsvp = self.hls_manifest_url - hlsvp = hlsvp.gsub("https://manifest.googlevideo.com", HOST_URL) - json.field "hlsUrl", hlsvp - end - - json.field "dashUrl", "#{HOST_URL}/api/manifest/dash/id/#{id}" - - json.field "adaptiveFormats" do - json.array do - self.adaptive_fmts.each do |fmt| - json.object do - # Only available on regular videos, not livestreams/OTF streams - if init_range = fmt["initRange"]? - json.field "init", "#{init_range["start"]}-#{init_range["end"]}" - end - if index_range = fmt["indexRange"]? - json.field "index", "#{index_range["start"]}-#{index_range["end"]}" - end - - # Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only) - json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? - - json.field "url", fmt["url"] - json.field "itag", fmt["itag"].as_i.to_s - json.field "type", fmt["mimeType"] - json.field "clen", fmt["contentLength"]? || "-1" - json.field "lmt", fmt["lastModified"] - json.field "projectionType", fmt["projectionType"] - - if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) - fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 - json.field "fps", fps - json.field "container", fmt_info["ext"] - json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] - - if fmt_info["height"]? - json.field "resolution", "#{fmt_info["height"]}p" - - quality_label = "#{fmt_info["height"]}p" - if fps > 30 - quality_label += "60" - end - json.field "qualityLabel", quality_label - - if fmt_info["width"]? - json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" - end - end - end - - # Livestream chunk infos - json.field "targetDurationSec", fmt["targetDurationSec"].as_i if fmt.has_key?("targetDurationSec") - json.field "maxDvrDurationSec", fmt["maxDvrDurationSec"].as_i if fmt.has_key?("maxDvrDurationSec") - - # Audio-related data - json.field "audioQuality", fmt["audioQuality"] if fmt.has_key?("audioQuality") - json.field "audioSampleRate", fmt["audioSampleRate"].as_s.to_i if fmt.has_key?("audioSampleRate") - json.field "audioChannels", fmt["audioChannels"] if fmt.has_key?("audioChannels") - - # Extra misc stuff - json.field "colorInfo", fmt["colorInfo"] if fmt.has_key?("colorInfo") - json.field "captionTrack", fmt["captionTrack"] if fmt.has_key?("captionTrack") - end - end - end - end - - json.field "formatStreams" do - json.array do - self.fmt_stream.each do |fmt| - json.object do - json.field "url", fmt["url"] - json.field "itag", fmt["itag"].as_i.to_s - json.field "type", fmt["mimeType"] - json.field "quality", fmt["quality"] - - fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) - if fmt_info - fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 - json.field "fps", fps - json.field "container", fmt_info["ext"] - json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] - - if fmt_info["height"]? - json.field "resolution", "#{fmt_info["height"]}p" - - quality_label = "#{fmt_info["height"]}p" - if fps > 30 - quality_label += "60" - end - json.field "qualityLabel", quality_label - - if fmt_info["width"]? - json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" - end - end - end - end - end - end - end + # Methods for API v1 JSON - json.field "captions" do - json.array do - self.captions.each do |caption| - json.object do - json.field "label", caption.name - json.field "language_code", caption.language_code - json.field "url", "/api/v1/captions/#{id}?label=#{URI.encode_www_form(caption.name)}" - end - end - end - end - - json.field "recommendedVideos" do - json.array do - self.related_videos.each do |rv| - if rv["id"]? - json.object do - json.field "videoId", rv["id"] - json.field "title", rv["title"] - json.field "videoThumbnails" do - generate_thumbnails(json, rv["id"]) - end - - json.field "author", rv["author"] - json.field "authorUrl", "/channel/#{rv["ucid"]?}" - json.field "authorId", rv["ucid"]? - if rv["author_thumbnail"]? - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", rv["author_thumbnail"].gsub(/s\d+-/, "s#{quality}-") - json.field "width", quality - json.field "height", quality - end - end - end - end - end - - json.field "lengthSeconds", rv["length_seconds"]?.try &.to_i - json.field "viewCountText", rv["short_view_count"]? - json.field "viewCount", rv["view_count"]?.try &.empty? ? nil : rv["view_count"].to_i64 - end - end - end - end - end - end + def to_json(locale : String?, json : JSON::Builder) + Invidious::JSONify::APIv1.video(self, json, locale: locale) end # TODO: remove the locale and follow the crystal convention def to_json(locale : String?, _json : Nil) - JSON.build { |json| to_json(locale, json) } + JSON.build do |json| + Invidious::JSONify::APIv1.video(self, json, locale: locale) + end end def to_json(json : JSON::Builder | Nil = nil) to_json(nil, json) end + # Misc methods + def video_type : VideoType video_type = info["videoType"]?.try &.as_s || "video" return VideoType.parse?(video_type) || VideoType::Video @@ -631,34 +422,3 @@ def build_thumbnails(id) {host: HOST_URL, height: 90, width: 120, name: "end", url: "3"}, } end - -def generate_thumbnails(json, id) - json.array do - build_thumbnails(id).each do |thumbnail| - json.object do - json.field "quality", thumbnail[:name] - json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" - json.field "width", thumbnail[:width] - json.field "height", thumbnail[:height] - end - end - end -end - -def generate_storyboards(json, id, storyboards) - json.array do - storyboards.each do |storyboard| - json.object do - json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}" - json.field "templateUrl", storyboard[:url] - json.field "width", storyboard[:width] - json.field "height", storyboard[:height] - json.field "count", storyboard[:count] - json.field "interval", storyboard[:interval] - json.field "storyboardWidth", storyboard[:storyboard_width] - json.field "storyboardHeight", storyboard[:storyboard_height] - json.field "storyboardCount", storyboard[:storyboard_count] - end - end - end -end -- cgit v1.2.3 From d659a451d6dece62dbb091a958083c8a347da5b1 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 23 Aug 2022 19:04:08 +0200 Subject: videos: remove unused 'VideoRedirect' exception --- src/invidious/jsonify/api_v1/common.cr | 18 ++++++++++++++++++ src/invidious/jsonify/api_v1/video_json.cr | 13 ------------- src/invidious/routes/api/manifest.cr | 2 -- src/invidious/routes/api/v1/videos.cr | 9 --------- src/invidious/routes/embed.cr | 2 -- src/invidious/routes/watch.cr | 2 -- src/invidious/videos.cr | 7 ------- 7 files changed, 18 insertions(+), 35 deletions(-) create mode 100644 src/invidious/jsonify/api_v1/common.cr (limited to 'src') diff --git a/src/invidious/jsonify/api_v1/common.cr b/src/invidious/jsonify/api_v1/common.cr new file mode 100644 index 00000000..64b06465 --- /dev/null +++ b/src/invidious/jsonify/api_v1/common.cr @@ -0,0 +1,18 @@ +require "json" + +module Invidious::JSONify::APIv1 + extend self + + def thumbnails(json : JSON::Builder, id : String) + json.array do + build_thumbnails(id).each do |thumbnail| + json.object do + json.field "quality", thumbnail[:name] + json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" + json.field "width", thumbnail[:width] + json.field "height", thumbnail[:height] + end + end + end + end +end diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 1082f6d3..0a5173ce 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -222,19 +222,6 @@ module Invidious::JSONify::APIv1 end end - def thumbnails(json, id) - json.array do - build_thumbnails(id).each do |thumbnail| - json.object do - json.field "quality", thumbnail[:name] - json.field "url", "#{thumbnail[:host]}/vi/#{id}/#{thumbnail["url"]}.jpg" - json.field "width", thumbnail[:width] - json.field "height", thumbnail[:height] - end - end - end - end - def storyboards(json, id, storyboards) json.array do storyboards.each do |storyboard| diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index bfb8a377..ae65f10d 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -14,8 +14,6 @@ module Invidious::Routes::API::Manifest begin video = get_video(id, region: region) - rescue ex : VideoRedirect - return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex : NotFoundException haltf env, status_code: 404 rescue ex diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 6f1f5916..a6b2eb4e 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -9,9 +9,6 @@ module Invidious::Routes::API::V1::Videos begin video = get_video(id, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex : NotFoundException return error_json(404, ex) rescue ex @@ -41,9 +38,6 @@ module Invidious::Routes::API::V1::Videos begin video = get_video(id, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex : NotFoundException haltf env, 404 rescue ex @@ -168,9 +162,6 @@ module Invidious::Routes::API::V1::Videos begin video = get_video(id, region: region) - rescue ex : VideoRedirect - env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) - return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) rescue ex : NotFoundException haltf env, 404 rescue ex diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index e6486587..289d87c9 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -131,8 +131,6 @@ module Invidious::Routes::Embed begin video = get_video(id, region: params.region) - rescue ex : VideoRedirect - return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex : NotFoundException return error_template(404, ex) rescue ex diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index fe1d8e54..5f481557 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -61,8 +61,6 @@ module Invidious::Routes::Watch begin video = get_video(id, region: params.region) - rescue ex : VideoRedirect - return env.redirect env.request.resource.gsub(id, ex.video_id) rescue ex : NotFoundException LOGGER.error("get_video not found: #{id} : #{ex.message}") return error_template(404, ex) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index fcc9a8a4..bec26de9 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -319,13 +319,6 @@ struct Video getset_bool isUpcoming end -class VideoRedirect < Exception - property video_id : String - - def initialize(@video_id) - end -end - def get_video(id, refresh = true, region = nil, force_refresh = false) if (video = Invidious::Database::Videos.select(id)) && !region # If record was last updated over 10 minutes ago, or video has since premiered, -- cgit v1.2.3 From 83795c245aace771fb73936b22d3de7ced0df9df Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 8 Sep 2022 00:06:58 +0200 Subject: videos: Support the new like button's structure --- src/invidious/videos/parser.cr | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index ff5d15de..701c4e77 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -227,11 +227,21 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") if toplevel_buttons - likes_button = toplevel_buttons.as_a + likes_button = toplevel_buttons.try &.as_a .find(&.dig?("toggleButtonRenderer", "defaultIcon", "iconType").=== "LIKE") .try &.["toggleButtonRenderer"] + # New format as of september 2022 + likes_button ||= toplevel_buttons.try &.as_a + .find(&.["segmentedLikeDislikeButtonRenderer"]?) + .try &.dig?( + "segmentedLikeDislikeButtonRenderer", + "likeButton", "toggleButtonRenderer" + ) + if likes_button + # Note: The like count from `toggledText` is off by one, as it would + # represent the new like count in the event where the user clicks on "like". likes_txt = (likes_button["defaultText"]? || likes_button["toggledText"]?) .try &.dig?("accessibility", "accessibilityData", "label") likes = likes_txt.as_s.gsub(/\D/, "").to_i64? if likes_txt -- cgit v1.2.3 From db91d3af66b52e8f7127b2b3b826111126027c6d Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 14 Sep 2022 20:08:36 +0200 Subject: videos: Fix some bugs --- src/invidious/jsonify/api_v1/video_json.cr | 11 ++++++++++- src/invidious/videos/parser.cr | 8 ++++---- 2 files changed, 14 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 0a5173ce..642789aa 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -93,7 +93,16 @@ module Invidious::JSONify::APIv1 json.field "itag", fmt["itag"].as_i.to_s json.field "type", fmt["mimeType"] json.field "clen", fmt["contentLength"]? || "-1" - json.field "lmt", fmt["lastModified"] + + # Last modified is a unix timestamp with µS, with the dot omitted. + # E.g: 1638056732(.)141582 + # + # On livestreams, it's not present, so always fall back to the + # current unix timestamp (up to mS precision) for compatibility. + last_modified = fmt["lastModified"]? + last_modified ||= "#{Time.utc.to_unix_ms.to_s}000" + json.field "lmt", last_modified + json.field "projectionType", fmt["projectionType"] if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 701c4e77..53372942 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -159,10 +159,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # We have to try to extract viewCount from videoPrimaryInfoRenderer first, # then from videoDetails, as the latter is "0" for livestreams (we want # to get the amount of viewers watching). - views = video_primary_renderer + views_txt = video_primary_renderer .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text") - .try &.as_s.to_i64 - views ||= video_details["viewCount"]?.try &.as_s.to_i64 + views_txt ||= video_details["viewCount"]? + views = views_txt.try &.as_s.gsub(/\D/, "").to_i64? length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) .try &.as_s.to_i64 @@ -270,7 +270,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any license = nil metadata.try &.each do |row| - metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s + metadata_title = extract_text(row.dig?("metadataRowRenderer", "title")) contents = row.dig?("metadataRowRenderer", "contents", 0) if metadata_title == "Category" -- cgit v1.2.3 From 2acff70811eeb82d7944b358e03171a775106e86 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 3 Oct 2022 21:58:52 +0200 Subject: videos: handle different JSON structs being present in cache --- src/invidious/videos.cr | 17 ++++++++++++++++- src/invidious/videos/parser.cr | 6 +++++- 2 files changed, 21 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index bec26de9..c055f2a7 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -7,6 +7,16 @@ end struct Video include DB::Serializable + # Version of the JSON structure + # It prevents us from loading an incompatible version from cache + # (either newer or older, if instances with different versions run + # concurrently, e.g during a version upgrade rollout). + # + # NOTE: don't forget to bump this number if any change is made to + # the `params` structure in videos/parser.cr!!! + # + SCHEMA_VERSION = 2 + property id : String @[DB::Field(converter: Video::JSONConverter)] @@ -55,6 +65,10 @@ struct Video return VideoType.parse?(video_type) || VideoType::Video end + def schema_version : Int + return info["version"]?.try &.as_i || 1 + end + def published : Time return info["published"]? .try { |t| Time.parse(t.as_s, "%Y-%m-%d", Time::Location::UTC) } || Time.utc @@ -326,7 +340,8 @@ def get_video(id, refresh = true, region = nil, force_refresh = false) if (refresh && (Time.utc - video.updated > 10.minutes) || (video.premiere_timestamp.try &.< Time.utc)) || - force_refresh + force_refresh || + video.schema_version != Video::SCHEMA_VERSION # cache control begin video = fetch_video(id, region) Invidious::Database::Videos.update(video) diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 53372942..64c8d21a 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -71,7 +71,8 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ # Stop here if video is not a scheduled livestream if playability_status != "LIVE_STREAM_OFFLINE" return { - "reason" => JSON::Any.new(reason), + "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), + "reason" => JSON::Any.new(reason), } end elsif video_id != player_response.dig("videoDetails", "videoId") @@ -121,6 +122,9 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ params[f] = player_response[f] if player_response[f]? end + # Data structure version, for cache control + params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) + return params end -- cgit v1.2.3 From f267394bbe2bd972e0157913ae253bfaa79ead0f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 31 Oct 2022 20:40:43 +0100 Subject: extractors: Add support for richGridRenderer --- src/invidious/yt_backend/extractors.cr | 49 +++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 21 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index dc65cc52..18b48152 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -436,21 +436,31 @@ private module Extractors content = extract_selected_tab(target["tabs"])["content"] if section_list_contents = content.dig?("sectionListRenderer", "contents") - section_list_contents.as_a.each do |renderer_container| - renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] - - # Category extraction - if items_container = renderer_container_contents["shelfRenderer"]? - raw_items << renderer_container_contents - next - elsif items_container = renderer_container_contents["gridRenderer"]? - else - items_container = renderer_container_contents - end + raw_items = unpack_section_list(section_list_contents) + elsif rich_grid_contents = content.dig?("richGridRenderer", "contents") + raw_items = rich_grid_contents.as_a + end - items_container["items"]?.try &.as_a.each do |item| - raw_items << item - end + return raw_items + end + + private def self.unpack_section_list(contents) + raw_items = [] of JSON::Any + + contents.as_a.each do |renderer_container| + renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] + + # Category extraction + if items_container = renderer_container_contents["shelfRenderer"]? + raw_items << renderer_container_contents + next + elsif items_container = renderer_container_contents["gridRenderer"]? + else + items_container = renderer_container_contents + end + + items_container["items"]?.try &.as_a.each do |item| + raw_items << item end end @@ -525,14 +535,11 @@ private module Extractors end private def self.extract(target) - raw_items = [] of JSON::Any - if content = target["gridContinuation"]? - raw_items = content["items"].as_a - elsif content = target["continuationItems"]? - raw_items = content.as_a - end + content = target["continuationItems"]? + content ||= target.dig?("gridContinuation", "items") + content ||= target.dig?("richGridContinuation", "contents") - return raw_items + return content.nil? ? [] of JSON::Any : content.as_a end def self.extractor_name -- cgit v1.2.3 From 46a63e6150f83bca90563068ebb12ecdf5e0d3c6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 31 Oct 2022 21:30:10 +0100 Subject: extractors: Add support for reelItemRenderer --- src/invidious/yt_backend/extractors.cr | 87 +++++++++++++++++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 18b48152..8112930d 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -17,6 +17,7 @@ private ITEM_PARSERS = { Parsers::PlaylistRendererParser, Parsers::CategoryRendererParser, Parsers::RichItemRendererParser, + Parsers::ReelItemRendererParser, } record AuthorFallback, name : String, id : String @@ -369,7 +370,7 @@ private module Parsers end # Parses an InnerTube richItemRenderer into a SearchVideo. - # Returns nil when the given object isn't a shelfRenderer + # Returns nil when the given object isn't a RichItemRenderer # # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used # by the result page for hashtags. It is located inside a continuationItems @@ -390,6 +391,90 @@ private module Parsers return {{@type.name}} end end + + # Parses an InnerTube reelItemRenderer into a SearchVideo. + # Returns nil when the given object isn't a reelItemRenderer + # + # reelItemRenderer items are used in the new (2022) channel layout, + # in the "shorts" tab. + # + module ReelItemRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["reelItemRenderer"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + video_id = item_contents["videoId"].as_s + + video_details_container = item_contents.dig( + "navigationEndpoint", "reelWatchEndpoint", + "overlay", "reelPlayerOverlayRenderer", + "reelPlayerHeaderSupportedRenderers", + "reelPlayerHeaderRenderer" + ) + + # Author infos + + author = video_details_container + .dig?("channelTitleText", "runs", 0, "text") + .try &.as_s || author_fallback.name + + ucid = video_details_container + .dig?("channelNavigationEndpoint", "browseEndpoint", "browseId") + .try &.as_s || author_fallback.id + + # Title & publication date + + title = video_details_container.dig?("reelTitleText") + .try { |t| extract_text(t) } || "" + + published = video_details_container + .dig?("timestampText", "simpleText") + .try { |t| decode_date(t.as_s) } || Time.utc + + # View count + + view_count_text = video_details_container.dig?("viewCountText", "simpleText") + view_count_text ||= video_details_container + .dig?("viewCountText", "accessibility", "accessibilityData", "label") + + view_count = view_count_text.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 + + # Duration + + a11y_data = item_contents + .dig?("accessibility", "accessibilityData", "label") + .try &.as_s || "" + + regex_match = /- (?\d+ minutes? )?(?\d+ seconds?)+ -/.match(a11y_data) + + minutes = regex_match.try &.["min"].to_i(strict: false) || 0 + seconds = regex_match.try &.["sec"].to_i(strict: false) || 0 + + duration = (minutes*60 + seconds) + + SearchVideo.new({ + title: title, + id: video_id, + author: author, + ucid: ucid, + published: published, + views: view_count, + description_html: "", + length_seconds: duration, + live_now: false, + premium: false, + premiere_timestamp: Time.unix(0), + author_verified: false, + }) + end + + def self.parser_name + return {{@type.name}} + end + end end # The following are the extractors for extracting an array of items from -- cgit v1.2.3 From 9da1827e957f9a8c4a370968b85007ad0f85c196 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 2 Nov 2022 00:58:33 +0100 Subject: Dirty fix to get back the channel videos --- spec/invidious/helpers_spec.cr | 10 ++--- src/invidious/channels/videos.cr | 90 +++++++++++++++++++--------------------- 2 files changed, 48 insertions(+), 52 deletions(-) (limited to 'src') diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr index 5ecebef3..ab361770 100644 --- a/spec/invidious/helpers_spec.cr +++ b/spec/invidious/helpers_spec.cr @@ -5,13 +5,13 @@ CONFIG = Config.from_yaml(File.open("config/config.example.yml")) Spectator.describe "Helper" do describe "#produce_channel_videos_url" do it "correctly produces url for requesting page `x` of a channel's videos" do - expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw")).to eq("/browse_ajax?continuation=4qmFsgI8EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4&gl=US&hl=en") + # expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw")).to eq("/browse_ajax?continuation=4qmFsgI8EhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4&gl=US&hl=en") + # + # expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4R0FFPQ%3D%3D&gl=US&hl=en") - expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0V4R0FFPQ%3D%3D&gl=US&hl=en") + # expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", page: 20)).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUE9PQ%3D%3D&gl=US&hl=en") - expect(produce_channel_videos_url(ucid: "UCXuqSBlHAE6Xw-yeJA0Tunw", page: 20)).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUE9PQ%3D%3D&gl=US&hl=en") - - expect(produce_channel_videos_url(ucid: "UC-9-kyTW8ZkZNDHQJ6FgpwQ", page: 20, sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQy05LWt5VFc4WmtaTkRIUUo2Rmdwd1EaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUJnQg%3D%3D&gl=US&hl=en") + # expect(produce_channel_videos_url(ucid: "UC-9-kyTW8ZkZNDHQJ6FgpwQ", page: 20, sort_by: "popular")).to eq("/browse_ajax?continuation=4qmFsgJAEhhVQy05LWt5VFc4WmtaTkRIUUo2Rmdwd1EaJEVnWjJhV1JsYjNNd0FqZ0JZQUZxQUxnQkFDQUFlZ0l5TUJnQg%3D%3D&gl=US&hl=en") end end diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index 48453bb7..b495e597 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -1,53 +1,48 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "videos", - "6:varint" => 2_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "13:string" => "", - "23:varint" => 0_i64, - }, + object_inner_2 = { + "2:0:embedded" => { + "1:0:varint" => 0_i64, }, + "5:varint" => 50_i64, + "6:varint" => 1_i64, + "7:varint" => (page * 30).to_i64, + "9:varint" => 1_i64, + "10:varint" => 0_i64, } - if !v2 - if auto_generated - seed = Time.unix(1525757349) - until seed >= Time.utc - seed += 1.month - end - timestamp = seed - (page - 1).months - - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x36_i64 - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{timestamp.to_unix}" - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = "#{page}" - end - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0_i64 + object_inner_2_encoded = object_inner_2 + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } - object["80226972:embedded"]["3:base64"].as(Hash)["61:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ - "1:string" => Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json({ - "1:varint" => 30_i64 * (page - 1), - }))), - }))) - end + object_inner_1 = { + "110:embedded" => { + "3:embedded" => { + "15:embedded" => { + "1:embedded" => { + "1:string" => object_inner_2_encoded, + "2:string" => "00000000-0000-0000-0000-000000000000", + }, + "3:varint" => 1_i64, + }, + }, + }, + } - case sort_by - when "newest" - when "popular" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x01_i64 - when "oldest" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 0x02_i64 - else nil # Ignore - end + object_inner_1_encoded = object_inner_1 + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } - object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) - object["80226972:embedded"].delete("3:base64") + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:string" => object_inner_1_encoded, + "35:string" => "browse-feed#{ucid}videos102", + }, + } continuation = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } @@ -67,10 +62,11 @@ end def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") videos = [] of SearchVideo - 2.times do |i| - initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) - videos.concat extract_videos(initial_data, author, ucid) - end + # 2.times do |i| + # initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) + initial_data = get_channel_videos_response(ucid, 1, auto_generated: auto_generated, sort_by: sort_by) + videos = extract_videos(initial_data, author, ucid) + # end return videos.size, videos end -- cgit v1.2.3 From 437f42250e381ab7652e07b4a413bb5d152356e1 Mon Sep 17 00:00:00 2001 From: Wes van der Vleuten <16665772+WesVleuten@users.noreply.github.com> Date: Mon, 7 Nov 2022 03:49:00 +0100 Subject: Watched marker --- src/invidious/views/components/item.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 0e959ff2..e53fa075 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -99,7 +99,7 @@ <% else %> <% if !env.get("preferences").as(Preferences).thin_mode %> -
    +
    "> <% if env.get? "show_watched" %>
    " method="post"> -- cgit v1.2.3 From 7b573817734dfd48fc6d1fbdc9a0a99f379f0ed1 Mon Sep 17 00:00:00 2001 From: Wes van der Vleuten <16665772+WesVleuten@users.noreply.github.com> Date: Mon, 7 Nov 2022 19:03:23 +0000 Subject: Added watch indicator --- assets/css/default.css | 13 +++++++++++++ assets/js/watched_widget.js | 27 +++++++++++++++++++++++++++ docker-compose.yml | 4 ++-- src/invidious/views/components/item.ecr | 7 ++++++- src/invidious/views/feeds/subscriptions.ecr | 3 ++- 5 files changed, 50 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index ab2b79e6..30a562e2 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -135,6 +135,9 @@ div.thumbnail { position: relative; box-sizing: border-box; } +div.thumbnail.thumbnail-watched { + background-color: rgba(255,255,255,.4); +} img.thumbnail { position: absolute; @@ -143,6 +146,16 @@ img.thumbnail { left: 0; top: 0; object-fit: cover; + z-index: -1; +} + +div.watched-indicator { + position: absolute; + left: 0; + bottom: 0; + height: 4px; + width: 100%; + background: red; } .length { diff --git a/assets/js/watched_widget.js b/assets/js/watched_widget.js index f1ac9cb4..10b33c1a 100644 --- a/assets/js/watched_widget.js +++ b/assets/js/watched_widget.js @@ -32,3 +32,30 @@ function mark_unwatched(target) { } }); } + + +var save_player_pos_key = 'save_player_pos'; + +function get_all_video_times() { + return helpers.storage.get(save_player_pos_key) || {}; +} + +var watchedIndicators = document.getElementsByClassName('watched-indicator'); +for (var i = 0; i < watchedIndicators.length; i++) { + var indicator = watchedIndicators[i]; + + var watched_part = get_all_video_times()[indicator.getAttribute('data-id')]; + var total = parseInt(indicator.getAttribute('data-length'), 10); + + var percentage = Math.round((watched_part / total) * 100); + + + if (percentage < 5) { + percentage = 5; + } + if (percentage > 90) { + percentage = 100; + } + + indicator.style.width = percentage + '%'; +} diff --git a/docker-compose.yml b/docker-compose.yml index eb83b020..48ee6a4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,7 +13,7 @@ services: dockerfile: docker/Dockerfile restart: unless-stopped ports: - - "127.0.0.1:3000:3000" + - "3003:3000" environment: # Please read the following file for a comprehensive list of all available # configuration options and their associated syntax: @@ -23,7 +23,7 @@ services: dbname: invidious user: kemal password: kemal - host: invidious-db + host: invidious-invidious-db-1 port: 5432 check_tables: true # external_port: diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index e53fa075..d63dca14 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -99,7 +99,8 @@ <% else %>
    <% if !env.get("preferences").as(Preferences).thin_mode %> -
    "> + <% item_watched = env.get("user") && env.get("user").as(User).watched && env.get("user").as(User).watched.index(item.id) != nil %> +
    "> <% if env.get? "show_watched" %> " method="post"> @@ -124,6 +125,10 @@ <% elsif item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> + + <% if item_watched %> +
    + <% end %>
    <% end %>

    <%= HTML.escape(item.title) %>

    diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr index 8d56ad14..add1eefc 100644 --- a/src/invidious/views/feeds/subscriptions.ecr +++ b/src/invidious/views/feeds/subscriptions.ecr @@ -50,7 +50,6 @@ }.to_pretty_json %> -
    <% videos.each do |item| %> @@ -58,6 +57,8 @@ <% end %>
    + +
    <% if page > 1 %> -- cgit v1.2.3 From f604c1c68bbba81310ca2fd0a7283482840e0a26 Mon Sep 17 00:00:00 2001 From: Wes van der Vleuten <16665772+WesVleuten@users.noreply.github.com> Date: Tue, 8 Nov 2022 23:15:42 +0100 Subject: Fixed thumbnails with darkreader, Added watched indicator in more locations --- assets/css/default.css | 16 +++++++++++----- assets/js/watched_widget.js | 4 +--- src/invidious/views/components/item.ecr | 16 ++++++++++++++-- 3 files changed, 26 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 30a562e2..890bd524 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -135,9 +135,7 @@ div.thumbnail { position: relative; box-sizing: border-box; } -div.thumbnail.thumbnail-watched { - background-color: rgba(255,255,255,.4); -} + img.thumbnail { position: absolute; @@ -146,7 +144,15 @@ img.thumbnail { left: 0; top: 0; object-fit: cover; - z-index: -1; +} + +div.watched-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(255,255,255,.4); } div.watched-indicator { @@ -155,7 +161,7 @@ div.watched-indicator { bottom: 0; height: 4px; width: 100%; - background: red; + background-color: red; } .length { diff --git a/assets/js/watched_widget.js b/assets/js/watched_widget.js index 10b33c1a..ffcdaad8 100644 --- a/assets/js/watched_widget.js +++ b/assets/js/watched_widget.js @@ -41,15 +41,13 @@ function get_all_video_times() { } var watchedIndicators = document.getElementsByClassName('watched-indicator'); +console.log('indicators', watchedIndicators.length); for (var i = 0; i < watchedIndicators.length; i++) { var indicator = watchedIndicators[i]; - var watched_part = get_all_video_times()[indicator.getAttribute('data-id')]; var total = parseInt(indicator.getAttribute('data-length'), 10); - var percentage = Math.round((watched_part / total) * 100); - if (percentage < 5) { percentage = 5; } diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index d63dca14..47d077cf 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -1,3 +1,5 @@ +<% item_watched = !item.is_a?(SearchChannel) && !item.is_a?(SearchPlaylist) && !item.is_a?(InvidiousPlaylist) && !item.is_a?(Category) && env.get("user") && env.get("user").as(User).watched && env.get("user").as(User).watched.index(item.id) != nil %> +
    <% case item when %> @@ -40,6 +42,11 @@ <% if item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> + + <% if item_watched %> +
    +
    + <% end %>
    <% end %>

    <%= HTML.escape(item.title) %>

    @@ -67,6 +74,11 @@ <% elsif item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> + + <% if item_watched %> +
    +
    + <% end %>
    <% end %>

    <%= HTML.escape(item.title) %>

    @@ -99,8 +111,7 @@ <% else %>
    <% if !env.get("preferences").as(Preferences).thin_mode %> - <% item_watched = env.get("user") && env.get("user").as(User).watched && env.get("user").as(User).watched.index(item.id) != nil %> -
    "> +
    <% if env.get? "show_watched" %> " method="post"> @@ -127,6 +138,7 @@ <% end %> <% if item_watched %> +
    <% end %>
    -- cgit v1.2.3 From c95ee10d6915bd1bb42e8e81f85848f1ad7b6240 Mon Sep 17 00:00:00 2001 From: Wes van der Vleuten <16665772+WesVleuten@users.noreply.github.com> Date: Tue, 8 Nov 2022 23:18:24 +0100 Subject: Added parital watch indicator on more locations --- src/invidious/views/add_playlist_items.ecr | 2 ++ src/invidious/views/channel.ecr | 2 ++ src/invidious/views/edit_playlist.ecr | 2 ++ src/invidious/views/feeds/playlists.ecr | 2 ++ src/invidious/views/feeds/popular.ecr | 2 ++ src/invidious/views/feeds/trending.ecr | 2 ++ src/invidious/views/hashtag.ecr | 2 ++ src/invidious/views/playlist.ecr | 2 ++ src/invidious/views/playlists.ecr | 2 ++ src/invidious/views/search.ecr | 2 ++ 10 files changed, 20 insertions(+) (limited to 'src') diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index 22870317..70575de3 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -39,6 +39,8 @@ <% end %>
    + + <% if query %> <%- query_encoded = URI.encode_www_form(query.text, space_to_plus: true) -%>
    diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index dea86abe..1295423e 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -110,6 +110,8 @@ <% end %>
    + +
    <% if page > 1 %> diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr index 89819ef0..100764c7 100644 --- a/src/invidious/views/edit_playlist.ecr +++ b/src/invidious/views/edit_playlist.ecr @@ -62,6 +62,8 @@ <% end %>
    + +
    <% if page > 1 %> diff --git a/src/invidious/views/feeds/playlists.ecr b/src/invidious/views/feeds/playlists.ecr index a59344c4..f9064762 100644 --- a/src/invidious/views/feeds/playlists.ecr +++ b/src/invidious/views/feeds/playlists.ecr @@ -32,3 +32,5 @@ <%= rendered "components/item" %> <% end %>
    + + diff --git a/src/invidious/views/feeds/popular.ecr b/src/invidious/views/feeds/popular.ecr index e77f35b9..919002cd 100644 --- a/src/invidious/views/feeds/popular.ecr +++ b/src/invidious/views/feeds/popular.ecr @@ -16,3 +16,5 @@ <%= rendered "components/item" %> <% end %>
    + + diff --git a/src/invidious/views/feeds/trending.ecr b/src/invidious/views/feeds/trending.ecr index a35c4ee3..76218165 100644 --- a/src/invidious/views/feeds/trending.ecr +++ b/src/invidious/views/feeds/trending.ecr @@ -45,3 +45,5 @@ <%= rendered "components/item" %> <% end %>
    + + diff --git a/src/invidious/views/hashtag.ecr b/src/invidious/views/hashtag.ecr index 0ecfe832..6064af74 100644 --- a/src/invidious/views/hashtag.ecr +++ b/src/invidious/views/hashtag.ecr @@ -24,6 +24,8 @@ <%- end -%>
    + +
    <%- if page > 1 -%> diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index df3112db..1df047ba 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -106,6 +106,8 @@ <% end %>
    + +
    <% if page > 1 %> diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index c8718e7b..6ce8b033 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -96,6 +96,8 @@ <% end %>
    + +
    diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index 254449a1..c4960d08 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -37,6 +37,8 @@
    <%- end -%> + +
    <%- if query.page > 1 -%> -- cgit v1.2.3 From cc5c83333f2a51dc178b698a548b64f01a2ff453 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 6 Nov 2022 21:23:58 +0100 Subject: videos: improve fetching of streaming data --- src/invidious/videos.cr | 6 ---- src/invidious/videos/parser.cr | 78 ++++++++++++++++++++++++++---------------- 2 files changed, 48 insertions(+), 36 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index c055f2a7..786ef416 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -380,12 +380,6 @@ def fetch_video(id, region) end end - # Try to fetch video info using an embedded client - if info["reason"]? - embed_info = extract_video_info(video_id: id, context_screen: "embed") - info = embed_info if !embed_info["reason"]? - end - if reason = info["reason"]? if reason == "Video unavailable" raise NotFoundException.new(reason.as_s || "") diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 64c8d21a..e3f6170d 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -50,12 +50,9 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? } end -def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) +def extract_video_info(video_id : String, proxy_region : String? = nil) # Init client config for the API client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) - if context_screen == "embed" - client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed - end # Fetch data from the player endpoint player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) @@ -69,7 +66,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ reason ||= player_response.dig("playabilityStatus", "reason").as_s # Stop here if video is not a scheduled livestream - if playability_status != "LIVE_STREAM_OFFLINE" + if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) return { "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), "reason" => JSON::Any.new(reason), @@ -84,7 +81,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end # Don't fetch the next endpoint if the video is unavailable. - if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status) + if {"OK", "LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) player_response = player_response.merge(next_response) end @@ -92,33 +89,34 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ params = parse_video_info(video_id, player_response) params["reason"] = JSON::Any.new(reason) if reason - # Fetch the video streams using an Android client in order to get the decrypted URLs and - # maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs: - # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 + new_player_response = nil + if reason.nil? - if context_screen == "embed" - client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed - else - client_config.client_type = YoutubeAPI::ClientType::Android - end - android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) - - # Sometimes, the video is available from the web client, but not on Android, so check - # that here, and fallback to the streaming data from the web client if needed. - # See: https://github.com/iv-org/invidious/issues/2549 - if video_id != android_player.dig("videoDetails", "videoId") - # YouTube may return a different video player response than expected. - # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 - raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (ANDROID client)") - elsif android_player["playabilityStatus"]["status"] == "OK" - params["streamingData"] = android_player["streamingData"]? || JSON::Any.new("") - else - params["streamingData"] = player_response["streamingData"]? || JSON::Any.new("") - end + # Fetch the video streams using an Android client in order to get the + # decrypted URLs and maybe fix throttling issues (#2194). See the + # following issue for an explanation about decrypted URLs: + # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 + client_config.client_type = YoutubeAPI::ClientType::Android + new_player_response = try_fetch_streaming_data(video_id, client_config) + elsif !reason.includes?("your country") # Handled separately + # The Android embedded client could help here + client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed + new_player_response = try_fetch_streaming_data(video_id, client_config) + end + + # Last hope + if new_player_response.nil? + client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed + new_player_response = try_fetch_streaming_data(video_id, client_config) + end + + # Replace player response and reset reason + if !new_player_response.nil? + player_response = new_player_response + params.delete("reason") end - # TODO: clean that up - {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| + {"captions", "playabilityStatus", "playerConfig", "storyboards", "streamingData"}.each do |f| params[f] = player_response[f] if player_response[f]? end @@ -128,6 +126,26 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ return params end +def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)? + LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") + response = YoutubeAPI.player(video_id: id, params: "", client_config: client_config) + + playability_status = response["playabilityStatus"]["status"] + LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") + + if id != response.dig("videoDetails", "videoId") + # YouTube may return a different video player response than expected. + # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 + raise VideoNotAvailableException.new( + "The video returned by YouTube isn't the requested one. (#{client_config.client_type} client)" + ) + elsif playability_status == "OK" + return response + else + return nil + end +end + def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any) # Top level elements -- cgit v1.2.3 From 47cc26cb3c5862e6ae96f89882ee08c6a8185672 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 16 Nov 2022 18:18:35 +0100 Subject: videos: fix 'Arithmetic overflow' error --- src/invidious/videos.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 786ef416..d626c7d1 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -280,7 +280,7 @@ struct Video {% for op, type in {i32: Int32, i64: Int64} %} private macro getset_{{op}}(name) def \{{name.id.underscore}} : {{type}} - return info[\{{name.stringify}}]?.try &.as_i.to_{{op}} || 0_{{op}} + return info[\{{name.stringify}}]?.try &.as_i64.to_{{op}} || 0_{{op}} end def \{{name.id.underscore}}=(value : Int) -- cgit v1.2.3 From afc0ec3c30d82b5cbbb38b09d3e57cdab2be5700 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 20 Nov 2022 22:52:33 +0100 Subject: search: Fix short text parsing --- src/invidious/channels/about.cr | 5 +++-- src/invidious/helpers/utils.cr | 24 +++++++++++------------- src/invidious/yt_backend/extractors.cr | 2 +- 3 files changed, 15 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index f60ee7af..4c442959 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -130,8 +130,9 @@ def get_about_info(ucid, locale) : AboutChannel tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map(&.["tabRenderer"]["title"].as_s.downcase) end - sub_count = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["subscriberCountText"]?.try &.["simpleText"]?.try &.as_s? - .try { |text| short_text_to_number(text.split(" ")[0]) } || 0 + sub_count = initdata + .dig?("header", "c4TabbedHeaderRenderer", "subscriberCountText", "simpleText").try &.as_s? + .try { |text| short_text_to_number(text.split(" ")[0]).to_i32 } || 0 AboutChannel.new( ucid: ucid, diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 8ae5034a..ed0cca38 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -161,21 +161,19 @@ def number_with_separator(number) number.to_s.reverse.gsub(/(\d{3})(?=\d)/, "\\1,").reverse end -def short_text_to_number(short_text : String) : Int32 - case short_text - when .ends_with? "M" - number = short_text.rstrip(" mM").to_f - number *= 1000000 - when .ends_with? "K" - number = short_text.rstrip(" kK").to_f - number *= 1000 - else - number = short_text.rstrip(" ") +def short_text_to_number(short_text : String) : Int64 + matches = /(?\d+(\.\d+)?)\s?(?[mMkKbB])?/.match(short_text) + number = matches.try &.["number"].to_f || 0.0 + + case matches.try &.["suffix"].downcase + when "k" then number *= 1_000 + when "m" then number *= 1_000_000 + when "b" then number *= 1_000_000_000 end - number = number.to_i - - return number + return number.to_i64 +rescue ex + return 0_i64 end def number_to_short_text(number) diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 8112930d..edc722cf 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -170,7 +170,7 @@ private module Parsers # Always simpleText # TODO change default value to nil subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") - .try { |s| short_text_to_number(s.as_s.split(" ")[0]) } || 0 + .try { |s| short_text_to_number(s.as_s.split(" ")[0]).to_i32 } || 0 # Auto-generated channels doesn't have videoCountText # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922 -- cgit v1.2.3 From f44506b7e032e8ed29dfc6a1c817442e4cf747f1 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 20 Nov 2022 23:48:59 +0100 Subject: yt api: bump web client version --- src/invidious/yt_backend/youtube_api.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 02327025..91a9332c 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -43,7 +43,7 @@ module YoutubeAPI ClientType::Web => { name: "WEB", name_proto: "1", - version: "2.20220804.07.00", + version: "2.20221118.01.00", api_key: DEFAULT_API_KEY, screen: "WATCH_FULL_SCREEN", os_name: "Windows", -- cgit v1.2.3 From fbcce57ce29b05c234c0c31b5f179d861e143260 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 11 Nov 2022 01:31:32 +0100 Subject: channel: use extractor utils to parse tabs (+ code cleaning) --- src/invidious/channels/about.cr | 62 ++++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 28 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 4c442959..bb9bd8c7 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -100,34 +100,40 @@ def get_about_info(ucid, locale) : AboutChannel total_views = 0_i64 joined = Time.unix(0) - tabs = [] of String - - tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]?.try &.as_a? - if !tabs_json.nil? - # Retrieve information from the tabs array. The index we are looking for varies between channels. - tabs_json.each do |node| - # Try to find the about section which is located in only one of the tabs. - channel_about_meta = node["tabRenderer"]?.try &.["content"]?.try &.["sectionListRenderer"]? - .try &.["contents"]?.try &.[0]?.try &.["itemSectionRenderer"]?.try &.["contents"]? - .try &.[0]?.try &.["channelAboutFullMetadataRenderer"]? - - if !channel_about_meta.nil? - total_views = channel_about_meta["viewCountText"]?.try &.["simpleText"]?.try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 - - # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. - joined = channel_about_meta["joinedDateText"]?.try &.["runs"]?.try &.as_a.reduce("") { |acc, nd| acc + nd["text"].as_s } - .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) - - # Normal Auto-generated channels - # https://support.google.com/youtube/answer/2579942 - # For auto-generated channels, channel_about_meta only has ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] - if (channel_about_meta["primaryLinks"]?.try &.size || 0) == 1 && (channel_about_meta["primaryLinks"][0]?) && - (channel_about_meta["primaryLinks"][0]["title"]?.try &.["simpleText"]?.try &.as_s? || "") == "Auto-generated by YouTube" - auto_generated = true - end - end + tab_names = [] of String + + if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]? + # Get the name of the tabs available on this channel + tab_names = tabs_json.as_a + .compact_map(&.dig?("tabRenderer", "title").try &.as_s.downcase) + + # Get the currently active tab ("About") + about_tab = extract_selected_tab(tabs_json) + + # Try to find the about metadata section + channel_about_meta = about_tab.dig?( + "content", + "sectionListRenderer", "contents", 0, + "itemSectionRenderer", "contents", 0, + "channelAboutFullMetadataRenderer" + ) + + if !channel_about_meta.nil? + total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 + + # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. + joined = extract_text(channel_about_meta["joinedDateText"]?) + .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) + + # Normal Auto-generated channels + # https://support.google.com/youtube/answer/2579942 + # For auto-generated channels, channel_about_meta only has + # ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] + auto_generated = ( + (channel_about_meta["primaryLinks"]?.try &.size) == 1 && \ + extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" + ) end - tabs = tabs_json.reject { |node| node["tabRenderer"]?.nil? }.map(&.["tabRenderer"]["title"].as_s.downcase) end sub_count = initdata @@ -148,7 +154,7 @@ def get_about_info(ucid, locale) : AboutChannel joined: joined, is_family_friendly: is_family_friendly, allowed_regions: allowed_regions, - tabs: tabs, + tabs: tab_names, verified: author_verified || false, ) end -- cgit v1.2.3 From 9588fcb5d1dd90e8591ed53a342727a0df6923c4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 3 Dec 2022 22:39:24 +0100 Subject: frontend: remove paging on channel videos --- src/invidious/routes/channels.cr | 5 +---- src/invidious/views/channel.ecr | 18 +++--------------- 2 files changed, 4 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index c6e02cbd..f26f29f5 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -12,9 +12,6 @@ module Invidious::Routes::Channels end locale, user, subscriptions, continuation, ucid, channel = data - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - sort_by = env.params.query["sort_by"]?.try &.downcase if channel.auto_generated @@ -35,7 +32,7 @@ module Invidious::Routes::Channels sort_options = {"newest", "oldest", "popular"} sort_by ||= "newest" - count, items = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + count, items = get_60_videos(channel.ucid, channel.author, 1, channel.auto_generated, sort_by) end templated "channel" diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index dea86abe..878587d4 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -90,7 +90,7 @@ <% if sort_by == sort %> <%= translate(locale, sort) %> <% else %> - + <%= translate(locale, sort) %> <% end %> @@ -111,19 +111,7 @@
    -- cgit v1.2.3 From bdc51cd20fd2df99c2fe5ddc281aada86000a783 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 10 Nov 2022 23:32:51 +0100 Subject: extractors: separate 'extract' and 'parse' logic --- src/invidious/channels/playlists.cr | 2 +- src/invidious/search/processors.cr | 2 +- src/invidious/yt_backend/extractors.cr | 54 +++++++++++++++++++--------------- 3 files changed, 33 insertions(+), 25 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index d5628f6a..e6c0a1d5 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -8,7 +8,7 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) items = [] of SearchItem continuation_items.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item| - extract_item(item, author, ucid).try { |t| items << t } + parse_item(item, author, ucid).try { |t| items << t } } continuation = continuation_items.as_a.last["continuationItemRenderer"]? diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr index d1409c06..683a4a7e 100644 --- a/src/invidious/search/processors.cr +++ b/src/invidious/search/processors.cr @@ -37,7 +37,7 @@ module Invidious::Search items = [] of SearchItem continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each do |item| - extract_item(item["itemSectionRenderer"]["contents"].as_a[0]).try { |t| items << t } + parse_item(item["itemSectionRenderer"]["contents"].as_a[0]).try { |t| items << t } end return items diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index edc722cf..a4b20d04 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -20,6 +20,8 @@ private ITEM_PARSERS = { Parsers::ReelItemRendererParser, } +private alias InitialData = Hash(String, JSON::Any) + record AuthorFallback, name : String, id : String # Namespace for logic relating to parsing InnerTube data into various datastructs. @@ -348,7 +350,7 @@ private module Parsers raw_contents = content_container["items"]?.try &.as_a if !raw_contents.nil? raw_contents.each do |item| - result = extract_item(item) + result = parse_item(item) if !result.nil? contents << result end @@ -510,7 +512,7 @@ private module Extractors # }] # module YouTubeTabs - def self.process(initial_data : Hash(String, JSON::Any)) + def self.process(initial_data : InitialData) if target = initial_data["twoColumnBrowseResultsRenderer"]? self.extract(target) end @@ -575,7 +577,7 @@ private module Extractors # } # module SearchResults - def self.process(initial_data : Hash(String, JSON::Any)) + def self.process(initial_data : InitialData) if target = initial_data["twoColumnSearchResultsRenderer"]? self.extract(target) end @@ -608,8 +610,8 @@ private module Extractors # The way they are structured is too varied to be accurately written down here. # However, they all eventually lead to an array of parsable items after traversing # through the JSON structure. - module Continuation - def self.process(initial_data : Hash(String, JSON::Any)) + module ContinuationContent + def self.process(initial_data : InitialData) if target = initial_data["continuationContents"]? self.extract(target) elsif target = initial_data["appendContinuationItemsAction"]? @@ -691,8 +693,7 @@ end # Parses an item from Youtube's JSON response into a more usable structure. # The end result can either be a SearchVideo, SearchPlaylist or SearchChannel. -def extract_item(item : JSON::Any, author_fallback : String? = "", - author_id_fallback : String? = "") +def parse_item(item : JSON::Any, author_fallback : String? = "", author_id_fallback : String? = "") # We "allow" nil values but secretly use empty strings instead. This is to save us the # hassle of modifying every author_fallback and author_id_fallback arg usage # which is more often than not nil. @@ -702,24 +703,23 @@ def extract_item(item : JSON::Any, author_fallback : String? = "", # Each parser automatically validates the data given to see if the data is # applicable to itself. If not nil is returned and the next parser is attempted. ITEM_PARSERS.each do |parser| - LOGGER.trace("extract_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") + LOGGER.trace("parse_item: Attempting to parse item using \"#{parser.parser_name}\" (cycling...)") if result = parser.process(item, author_fallback) - LOGGER.debug("extract_item: Successfully parsed via #{parser.parser_name}") - + LOGGER.debug("parse_item: Successfully parsed via #{parser.parser_name}") return result else - LOGGER.trace("extract_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...") + LOGGER.trace("parse_item: Parser \"#{parser.parser_name}\" does not apply. Cycling to the next one...") end end end # Parses multiple items from YouTube's initial JSON response into a more usable structure. # The end result is an array of SearchItem. -def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, - author_id_fallback : String? = nil) : Array(SearchItem) - items = [] of SearchItem - +# +# This function yields the container so that items can be parsed separately. +# +def extract_items(initial_data : InitialData, &block) if unpackaged_data = initial_data["contents"]?.try &.as_h elsif unpackaged_data = initial_data["response"]?.try &.as_h elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h @@ -727,24 +727,32 @@ def extract_items(initial_data : Hash(String, JSON::Any), author_fallback : Stri unpackaged_data = initial_data end - # This is identical to the parser cycling of extract_item(). + # This is identical to the parser cycling of parse_item(). ITEM_CONTAINER_EXTRACTOR.each do |extractor| LOGGER.trace("extract_items: Attempting to extract item container using \"#{extractor.extractor_name}\" (cycling...)") if container = extractor.process(unpackaged_data) LOGGER.debug("extract_items: Successfully unpacked container with \"#{extractor.extractor_name}\"") # Extract items in container - container.each do |item| - if parsed_result = extract_item(item, author_fallback, author_id_fallback) - items << parsed_result - end - end - - break + container.each { |item| yield item } else LOGGER.trace("extract_items: Extractor \"#{extractor.extractor_name}\" does not apply. Cycling to the next one...") end end +end + +# Wrapper using the block function above +def extract_items( + initial_data : InitialData, + author_fallback : String? = nil, + author_id_fallback : String? = nil +) : Array(SearchItem) + items = [] of SearchItem + + extract_items(initial_data) do |item| + parsed = parse_item(item, author_fallback, author_id_fallback) + items << parsed if !parsed.nil? + end return items end -- cgit v1.2.3 From ce7db8d2cb87111af15de2de9faf12aae38283bb Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 5 Nov 2022 18:56:35 +0100 Subject: extractors: Add continuation token parser --- spec/invidious/hashtag_spec.cr | 4 +-- src/invidious/channels/playlists.cr | 16 ++------- src/invidious/hashtag.cr | 3 +- src/invidious/helpers/serialized_yt_data.cr | 7 ++++ src/invidious/search/processors.cr | 14 ++------ src/invidious/yt_backend/extractors.cr | 54 +++++++++++++++++++++------- src/invidious/yt_backend/extractors_utils.cr | 27 +++----------- 7 files changed, 63 insertions(+), 62 deletions(-) (limited to 'src') diff --git a/spec/invidious/hashtag_spec.cr b/spec/invidious/hashtag_spec.cr index 77676878..266ec57b 100644 --- a/spec/invidious/hashtag_spec.cr +++ b/spec/invidious/hashtag_spec.cr @@ -4,7 +4,7 @@ Spectator.describe Invidious::Hashtag do it "parses richItemRenderer containers (test 1)" do # Enable mock test_content = load_mock("hashtag/martingarrix_page1") - videos = extract_items(test_content) + videos, _ = extract_items(test_content) expect(typeof(videos)).to eq(Array(SearchItem)) expect(videos.size).to eq(60) @@ -57,7 +57,7 @@ Spectator.describe Invidious::Hashtag do it "parses richItemRenderer containers (test 2)" do # Enable mock test_content = load_mock("hashtag/martingarrix_page2") - videos = extract_items(test_content) + videos, _ = extract_items(test_content) expect(typeof(videos)).to eq(Array(SearchItem)) expect(videos.size).to eq(60) diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index e6c0a1d5..0d46499a 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -1,18 +1,7 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) if continuation response_json = YoutubeAPI.browse(continuation) - continuation_items = response_json["onResponseReceivedActions"]? - .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - - return [] of SearchItem, nil if !continuation_items - - items = [] of SearchItem - continuation_items.as_a.select(&.as_h.has_key?("gridPlaylistRenderer")).each { |item| - parse_item(item, author, ucid).try { |t| items << t } - } - - continuation = continuation_items.as_a.last["continuationItemRenderer"]? - .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s + items, continuation = extract_items(response_json, author, ucid) else url = "/channel/#{ucid}/playlists?flow=list&view=1" @@ -30,8 +19,7 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) initial_data = extract_initial_data(response.body) return [] of SearchItem, nil if !initial_data - items = extract_items(initial_data, author, ucid) - continuation = response.body.match(/"token":"(?[^"]+)"/).try &.["continuation"]? + items, continuation = extract_items(initial_data, author, ucid) end return items, continuation diff --git a/src/invidious/hashtag.cr b/src/invidious/hashtag.cr index afe31a36..bc329205 100644 --- a/src/invidious/hashtag.cr +++ b/src/invidious/hashtag.cr @@ -8,7 +8,8 @@ module Invidious::Hashtag client_config = YoutubeAPI::ClientConfig.new(region: region) response = YoutubeAPI.browse(continuation: ctoken, client_config: client_config) - return extract_items(response) + items, _ = extract_items(response) + return items end def generate_continuation(hashtag : String, cursor : Int) diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index c52e2a0d..635f0984 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -265,4 +265,11 @@ class Category end end +struct Continuation + getter token + + def initialize(@token : String) + end +end + alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr index 683a4a7e..7e909590 100644 --- a/src/invidious/search/processors.cr +++ b/src/invidious/search/processors.cr @@ -9,7 +9,8 @@ module Invidious::Search client_config = YoutubeAPI::ClientConfig.new(region: query.region) initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config) - return extract_items(initial_data) + items, _ = extract_items(initial_data) + return items end # Search a youtube channel @@ -30,16 +31,7 @@ module Invidious::Search continuation = produce_channel_search_continuation(ucid, query.text, query.page) response_json = YoutubeAPI.browse(continuation) - continuation_items = response_json["onResponseReceivedActions"]? - .try &.[0]["appendContinuationItemsAction"]["continuationItems"] - - return [] of SearchItem if !continuation_items - - items = [] of SearchItem - continuation_items.as_a.select(&.as_h.has_key?("itemSectionRenderer")).each do |item| - parse_item(item["itemSectionRenderer"]["contents"].as_a[0]).try { |t| items << t } - end - + items, _ = extract_items(response_json, "", ucid) return items end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index a4b20d04..baf52118 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -7,7 +7,7 @@ require "../helpers/serialized_yt_data" private ITEM_CONTAINER_EXTRACTOR = { Extractors::YouTubeTabs, Extractors::SearchResults, - Extractors::Continuation, + Extractors::ContinuationContent, } private ITEM_PARSERS = { @@ -18,6 +18,7 @@ private ITEM_PARSERS = { Parsers::CategoryRendererParser, Parsers::RichItemRendererParser, Parsers::ReelItemRendererParser, + Parsers::ContinuationItemRendererParser, } private alias InitialData = Hash(String, JSON::Any) @@ -347,14 +348,9 @@ private module Parsers content_container = item_contents["contents"] end - raw_contents = content_container["items"]?.try &.as_a - if !raw_contents.nil? - raw_contents.each do |item| - result = parse_item(item) - if !result.nil? - contents << result - end - end + content_container["items"]?.try &.as_a.each do |item| + result = parse_item(item, author_fallback.name, author_fallback.id) + contents << result if result.is_a?(SearchItem) end Category.new({ @@ -477,6 +473,35 @@ private module Parsers return {{@type.name}} end end + + # Parses an InnerTube continuationItemRenderer into a Continuation. + # Returns nil when the given object isn't a continuationItemRenderer. + # + # continuationItemRenderer contains various metadata ued to load more + # content (i.e when the user scrolls down). The interesting bit is the + # protobuf object known as the "continutation token". Previously, those + # were generated from sratch, but recent (as of 11/2022) Youtube changes + # are forcing us to extract them from replies. + # + module ContinuationItemRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["continuationItemRenderer"]? + return self.parse(item_contents) + end + end + + private def self.parse(item_contents) + token = item_contents + .dig?("continuationEndpoint", "continuationCommand", "token") + .try &.as_s + + return Continuation.new(token) if token + end + + def self.parser_name + return {{@type.name}} + end + end end # The following are the extractors for extracting an array of items from @@ -746,13 +771,18 @@ def extract_items( initial_data : InitialData, author_fallback : String? = nil, author_id_fallback : String? = nil -) : Array(SearchItem) +) : {Array(SearchItem), String?} items = [] of SearchItem + continuation = nil extract_items(initial_data) do |item| parsed = parse_item(item, author_fallback, author_id_fallback) - items << parsed if !parsed.nil? + + case parsed + when .is_a?(Continuation) then continuation = parsed.token + when .is_a?(SearchItem) then items << parsed + end end - return items + return items, continuation end diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index f8245160..0cb3c079 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -68,10 +68,10 @@ rescue ex return false end -def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) - extracted = extract_items(initial_data, author_fallback, author_id_fallback) +def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) : Array(SearchVideo) + extracted, _ = extract_items(initial_data, author_fallback, author_id_fallback) - target = [] of SearchItem + target = [] of (SearchItem | Continuation) extracted.each do |i| if i.is_a?(Category) i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video } @@ -79,28 +79,11 @@ def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : Str target << i end end - return target.select(SearchVideo).map(&.as(SearchVideo)) + + return target.select(SearchVideo) end def extract_selected_tab(tabs) # Extract the selected tab from the array of tabs Youtube returns return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"] end - -def fetch_continuation_token(items : Array(JSON::Any)) - # Fetches the continuation token from an array of items - return items.last["continuationItemRenderer"]? - .try &.["continuationEndpoint"]["continuationCommand"]["token"].as_s -end - -def fetch_continuation_token(initial_data : Hash(String, JSON::Any)) - # Fetches the continuation token from initial data - if initial_data["onResponseReceivedActions"]? - continuation_items = initial_data["onResponseReceivedActions"][0]["appendContinuationItemsAction"]["continuationItems"] - else - tab = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]) - continuation_items = tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["gridRenderer"]["items"] - end - - return fetch_continuation_token(continuation_items.as_a) -end -- cgit v1.2.3 From 8e8ca4fcc5cfcb7bebc3f29440d6abc1de770513 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 12 Nov 2022 00:04:27 +0100 Subject: Prepare to create a 'Channel' module --- src/invidious.cr | 9 ++++++++- src/invidious/jobs/notification_job.cr | 4 ++-- src/invidious/jobs/refresh_channels_job.cr | 2 +- src/invidious/jobs/refresh_feeds_job.cr | 2 +- src/invidious/jobs/subscribe_to_feeds_job.cr | 2 +- 5 files changed, 13 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 2874cc71..5064f0b8 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -48,6 +48,13 @@ require "./invidious/search/*" require "./invidious/routes/**" require "./invidious/jobs/**" +# Declare the base namespace for invidious +module Invidious +end + +# Simple alias to make code easier to read +alias IV = Invidious + CONFIG = Config.load HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) @@ -172,7 +179,7 @@ if CONFIG.popular_enabled Invidious::Jobs.register Invidious::Jobs::PullPopularVideosJob.new(PG_DB) end -CONNECTION_CHANNEL = Channel({Bool, Channel(PQ::Notification)}).new(32) +CONNECTION_CHANNEL = ::Channel({Bool, ::Channel(PQ::Notification)}).new(32) Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL, CONFIG.database_url) Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new diff --git a/src/invidious/jobs/notification_job.cr b/src/invidious/jobs/notification_job.cr index 2f525e08..b445107b 100644 --- a/src/invidious/jobs/notification_job.cr +++ b/src/invidious/jobs/notification_job.cr @@ -1,12 +1,12 @@ class Invidious::Jobs::NotificationJob < Invidious::Jobs::BaseJob - private getter connection_channel : Channel({Bool, Channel(PQ::Notification)}) + private getter connection_channel : ::Channel({Bool, ::Channel(PQ::Notification)}) private getter pg_url : URI def initialize(@connection_channel, @pg_url) end def begin - connections = [] of Channel(PQ::Notification) + connections = [] of ::Channel(PQ::Notification) PG.connect_listen(pg_url, "notifications") { |event| connections.each(&.send(event)) } diff --git a/src/invidious/jobs/refresh_channels_job.cr b/src/invidious/jobs/refresh_channels_job.cr index 92681408..80812a63 100644 --- a/src/invidious/jobs/refresh_channels_job.cr +++ b/src/invidious/jobs/refresh_channels_job.cr @@ -8,7 +8,7 @@ class Invidious::Jobs::RefreshChannelsJob < Invidious::Jobs::BaseJob max_fibers = CONFIG.channel_threads lim_fibers = max_fibers active_fibers = 0 - active_channel = Channel(Bool).new + active_channel = ::Channel(Bool).new backoff = 2.minutes loop do diff --git a/src/invidious/jobs/refresh_feeds_job.cr b/src/invidious/jobs/refresh_feeds_job.cr index 4b52c959..4f8130df 100644 --- a/src/invidious/jobs/refresh_feeds_job.cr +++ b/src/invidious/jobs/refresh_feeds_job.cr @@ -7,7 +7,7 @@ class Invidious::Jobs::RefreshFeedsJob < Invidious::Jobs::BaseJob def begin max_fibers = CONFIG.feed_threads active_fibers = 0 - active_channel = Channel(Bool).new + active_channel = ::Channel(Bool).new loop do db.query("SELECT email FROM users WHERE feed_needs_update = true OR feed_needs_update IS NULL") do |rs| diff --git a/src/invidious/jobs/subscribe_to_feeds_job.cr b/src/invidious/jobs/subscribe_to_feeds_job.cr index a431a48a..8584fb9c 100644 --- a/src/invidious/jobs/subscribe_to_feeds_job.cr +++ b/src/invidious/jobs/subscribe_to_feeds_job.cr @@ -12,7 +12,7 @@ class Invidious::Jobs::SubscribeToFeedsJob < Invidious::Jobs::BaseJob end active_fibers = 0 - active_channel = Channel(Bool).new + active_channel = ::Channel(Bool).new loop do db.query_all("SELECT id FROM channels WHERE CURRENT_TIMESTAMP - subscribed > interval '4 days' OR subscribed IS NULL") do |rs| -- cgit v1.2.3 From c5ee2bfc0f5e485f91e53dedc879312c3e729be8 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 11 Nov 2022 00:44:24 +0100 Subject: channel: use YT API to fetch playlist items --- src/invidious/channels/playlists.cr | 40 +++++++++++++++++++------------------ 1 file changed, 21 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index 0d46499a..8fdac3a7 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -1,28 +1,30 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) if continuation - response_json = YoutubeAPI.browse(continuation) - items, continuation = extract_items(response_json, author, ucid) + initial_data = YoutubeAPI.browse(continuation) else - url = "/channel/#{ucid}/playlists?flow=list&view=1" + params = + case sort_by + when "last", "last_added" + # Equivalent to "&sort=lad" + # {"2:string": "playlists", "3:varint": 4, "4:varint": 1, "6:varint": 1} + "EglwbGF5bGlzdHMYBCABMAE%3D" + when "oldest", "oldest_created" + # formerly "&sort=da" + # Not available anymore :c or maybe ?? + # {"2:string": "playlists", "3:varint": 2, "4:varint": 1, "6:varint": 1} + "EglwbGF5bGlzdHMYAiABMAE%3D" + # {"2:string": "playlists", "3:varint": 1, "4:varint": 1, "6:varint": 1} + # "EglwbGF5bGlzdHMYASABMAE%3D" + when "newest", "newest_created" + # Formerly "&sort=dd" + # {"2:string": "playlists", "3:varint": 3, "4:varint": 1, "6:varint": 1} + "EglwbGF5bGlzdHMYAyABMAE%3D" + end - case sort_by - when "last", "last_added" - # - when "oldest", "oldest_created" - url += "&sort=da" - when "newest", "newest_created" - url += "&sort=dd" - else nil # Ignore - end - - response = YT_POOL.client &.get(url) - initial_data = extract_initial_data(response.body) - return [] of SearchItem, nil if !initial_data - - items, continuation = extract_items(initial_data, author, ucid) + initial_data = YoutubeAPI.browse(ucid, params: params || "") end - return items, continuation + return extract_items(initial_data, ucid, author) end # ## NOTE: DEPRECATED -- cgit v1.2.3 From 2903e896ecf2404bf932438a33432125a6ad1fca Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 11 Nov 2022 20:26:34 +0100 Subject: channel: use YT API + extractors to fetch videos --- src/invidious/channels/channels.cr | 67 ++++++++++++------------- src/invidious/channels/playlists.cr | 2 +- src/invidious/channels/videos.cr | 88 ++++++++++++++++++++++++--------- src/invidious/routes/api/v1/channels.cr | 66 ++++++++++--------------- src/invidious/routes/channels.cr | 4 +- 5 files changed, 125 insertions(+), 102 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index e3d3d9ee..27369f12 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -180,11 +180,16 @@ def fetch_channel(ucid, pull_all_videos : Bool) LOGGER.trace("fetch_channel: #{ucid} : author = #{author}, auto_generated = #{auto_generated}") - page = 1 + channel = InvidiousChannel.new({ + id: ucid, + author: author, + updated: Time.utc, + deleted: false, + subscribed: nil, + }) LOGGER.trace("fetch_channel: #{ucid} : Downloading channel videos page") - initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - videos = extract_videos(initial_data, author, ucid) + videos, continuation = IV::Channel::Tabs.get_videos(channel) LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") rss.xpath_nodes("//feed/entry").each do |entry| @@ -197,7 +202,9 @@ def fetch_channel(ucid, pull_all_videos : Bool) views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64? views ||= 0_i64 - channel_video = videos.select { |video| video.id == video_id }[0]? + channel_video = videos + .select(SearchVideo) + .select(&.id.== video_id)[0]? length_seconds = channel_video.try &.length_seconds length_seconds ||= 0 @@ -235,30 +242,25 @@ def fetch_channel(ucid, pull_all_videos : Bool) end if pull_all_videos - page += 1 - - ids = [] of String - loop do - initial_data = get_channel_videos_response(ucid, page, auto_generated: auto_generated) - videos = extract_videos(initial_data, author, ucid) - - count = videos.size - videos = videos.map { |video| ChannelVideo.new({ - id: video.id, - title: video.title, - published: video.published, - updated: Time.utc, - ucid: video.ucid, - author: video.author, - length_seconds: video.length_seconds, - live_now: video.live_now, - premiere_timestamp: video.premiere_timestamp, - views: video.views, - }) } - - videos.each do |video| - ids << video.id + # Keep fetching videos using the continuation token retrieved earlier + videos, continuation = IV::Channel::Tabs.get_videos(channel, continuation: continuation) + + count = 0 + videos.select(SearchVideo).each do |video| + count += 1 + video = ChannelVideo.new({ + id: video.id, + title: video.title, + published: video.published, + updated: Time.utc, + ucid: video.ucid, + author: video.author, + length_seconds: video.length_seconds, + live_now: video.live_now, + premiere_timestamp: video.premiere_timestamp, + views: video.views, + }) # We are notified of Red videos elsewhere (PubSub), which includes a correct published date, # so since they don't provide a published date here we can safely ignore them. @@ -269,17 +271,10 @@ def fetch_channel(ucid, pull_all_videos : Bool) end break if count < 25 - page += 1 + sleep 500.milliseconds end end - channel = InvidiousChannel.new({ - id: ucid, - author: author, - updated: Time.utc, - deleted: false, - subscribed: nil, - }) - + channel.updated = Time.utc return channel end diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index 8fdac3a7..772eecb9 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -24,7 +24,7 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) initial_data = YoutubeAPI.browse(ucid, params: params || "") end - return extract_items(initial_data, ucid, author) + return extract_items(initial_data, author, ucid) end # ## NOTE: DEPRECATED diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index b495e597..23ad4e02 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -16,6 +16,14 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so .try { |i| Base64.urlsafe_encode(i) } .try { |i| URI.encode_www_form(i) } + sort_by_numerical = + case sort_by + when "newest" then 1_i64 + when "popular" then 2_i64 + when "oldest" then 3_i64 # Broken as of 10/2022 :c + else 1_i64 # Fallback to "newest" + end + object_inner_1 = { "110:embedded" => { "3:embedded" => { @@ -24,7 +32,7 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so "1:string" => object_inner_2_encoded, "2:string" => "00000000-0000-0000-0000-000000000000", }, - "3:varint" => 1_i64, + "3:varint" => sort_by_numerical, }, }, }, @@ -52,34 +60,66 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so return continuation end -def get_channel_videos_response(ucid, page = 1, auto_generated = nil, sort_by = "newest") - continuation = produce_channel_videos_continuation(ucid, page, - auto_generated: auto_generated, sort_by: sort_by, v2: true) - - return YoutubeAPI.browse(continuation) +# Used in bypass_captcha_job.cr +def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) + continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2) + return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" end -def get_60_videos(ucid, author, page, auto_generated, sort_by = "newest") - videos = [] of SearchVideo +module Invidious::Channel::Tabs + extend self - # 2.times do |i| - # initial_data = get_channel_videos_response(ucid, page * 2 + (i - 1), auto_generated: auto_generated, sort_by: sort_by) - initial_data = get_channel_videos_response(ucid, 1, auto_generated: auto_generated, sort_by: sort_by) - videos = extract_videos(initial_data, author, ucid) - # end + # ------------------- + # Regular videos + # ------------------- - return videos.size, videos -end + def make_initial_video_ctoken(ucid, sort_by) : String + return produce_channel_videos_continuation(ucid, sort_by: sort_by) + end -def get_latest_videos(ucid) - initial_data = get_channel_videos_response(ucid) - author = initial_data["metadata"]?.try &.["channelMetadataRenderer"]?.try &.["title"]?.try &.as_s + # Wrapper for AboutChannel, as we still need to call get_videos with + # an author name and ucid directly (e.g in RSS feeds). + # TODO: figure out how to get rid of that + def get_videos(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") + return get_videos( + channel.author, channel.ucid, + continuation: continuation, sort_by: sort_by + ) + end - return extract_videos(initial_data, author, ucid) -end + # Wrapper for InvidiousChannel, as we still need to call get_videos with + # an author name and ucid directly (e.g in RSS feeds). + # TODO: figure out how to get rid of that + def get_videos(channel : InvidiousChannel, *, continuation : String? = nil, sort_by = "newest") + return get_videos( + channel.author, channel.id, + continuation: continuation, sort_by: sort_by + ) + end -# Used in bypass_captcha_job.cr -def produce_channel_videos_url(ucid, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) - continuation = produce_channel_videos_continuation(ucid, page, auto_generated, sort_by, v2) - return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" + def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest") + continuation ||= make_initial_video_ctoken(ucid, sort_by) + initial_data = YoutubeAPI.browse(continuation: continuation) + + return extract_items(initial_data, author, ucid) + end + + def get_60_videos(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") + if continuation.nil? + # Fetch the first "page" of video + items, next_continuation = get_videos(channel, sort_by: sort_by) + else + # Fetch a "page" of videos using the given continuation token + items, next_continuation = get_videos(channel, continuation: continuation) + end + + # If there is more to load, then load a second "page" + # and replace the previous continuation token + if !next_continuation.nil? + items_2, next_continuation = get_videos(channel, continuation: next_continuation) + items.concat items_2 + end + + return items, next_continuation + end end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 6b81c546..72d9ae5f 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -5,8 +5,6 @@ module Invidious::Routes::API::V1::Channels env.response.content_type = "application/json" ucid = env.params.url["ucid"] - sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" begin channel = get_about_info(ucid, locale) @@ -19,16 +17,13 @@ module Invidious::Routes::API::V1::Channels return error_json(500, ex) end - page = 1 - if channel.auto_generated - videos = [] of SearchVideo - count = 0 - else - begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) - rescue ex - return error_json(500, ex) - end + # Retrieve "sort by" setting from URL parameters + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + + begin + videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by) + rescue ex + return error_json(500, ex) end JSON.build do |json| @@ -134,25 +129,11 @@ module Invidious::Routes::API::V1::Channels end def self.latest(env) - locale = env.get("preferences").as(Preferences).locale - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - - begin - videos = get_latest_videos(ucid) - rescue ex - return error_json(500, ex) - end + # Remove parameters that could affect this endpoint's behavior + env.params.query.delete("sort_by") if env.params.query.has_key?("sort_by") + env.params.query.delete("continuation") if env.params.query.has_key?("continuation") - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) - end - end - end + return self.videos(env) end def self.videos(env) @@ -161,11 +142,6 @@ module Invidious::Routes::API::V1::Channels env.response.content_type = "application/json" ucid = env.params.url["ucid"] - page = env.params.query["page"]?.try &.to_i? - page ||= 1 - sort_by = env.params.query["sort"]?.try &.downcase - sort_by ||= env.params.query["sort_by"]?.try &.downcase - sort_by ||= "newest" begin channel = get_about_info(ucid, locale) @@ -178,17 +154,27 @@ module Invidious::Routes::API::V1::Channels return error_json(500, ex) end + # Retrieve some URL parameters + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + continuation = env.params.query["continuation"]? + begin - count, videos = get_60_videos(channel.ucid, channel.author, page, channel.auto_generated, sort_by) + videos, next_continuation = Channel::Tabs.get_60_videos( + channel, continuation: continuation, sort_by: sort_by + ) rescue ex return error_json(500, ex) end - JSON.build do |json| - json.array do - videos.each do |video| - video.to_json(locale, json) + return JSON.build do |json| + json.object do + json.field "videos" do + json.array do + videos.each &.to_json(locale, json) + end end + + json.field "continuation", next_continuation if next_continuation end end end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index f26f29f5..2773deb7 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -32,7 +32,9 @@ module Invidious::Routes::Channels sort_options = {"newest", "oldest", "popular"} sort_by ||= "newest" - count, items = get_60_videos(channel.ucid, channel.author, 1, channel.auto_generated, sort_by) + items, continuation = Channel::Tabs.get_60_videos( + channel, continuation: continuation, sort_by: sort_by + ) end templated "channel" -- cgit v1.2.3 From 52ef89f02d0ab29fd0f218abc4051328b3d96809 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 12 Nov 2022 00:09:03 +0100 Subject: channel: Add support for shorts and livestreams (backend only) --- src/invidious/channels/videos.cr | 50 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) (limited to 'src') diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index 23ad4e02..bea406c1 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -122,4 +122,54 @@ module Invidious::Channel::Tabs return items, next_continuation end + + # ------------------- + # Shorts + # ------------------- + + def get_shorts(channel : AboutChannel, continuation : String? = nil) + if continuation.nil? + # EgZzaG9ydHPyBgUKA5oBAA%3D%3D is the protobuf object to load "shorts" + # TODO: try to extract the continuation tokens that allows other sorting options + initial_data = YoutubeAPI.browse(channel.ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D") + else + initial_data = YoutubeAPI.browse(continuation: continuation) + end + + return extract_items(initial_data, channel.author, channel.ucid) + end + + # ------------------- + # Livestreams + # ------------------- + + def get_livestreams(channel : AboutChannel, continuation : String? = nil) + if continuation.nil? + # EgdzdHJlYW1z8gYECgJ6AA%3D%3D is the protobuf object to load "streams" + initial_data = YoutubeAPI.browse(channel.ucid, params: "EgdzdHJlYW1z8gYECgJ6AA%3D%3D") + else + initial_data = YoutubeAPI.browse(continuation: continuation) + end + + return extract_items(initial_data, channel.author, channel.ucid) + end + + def get_60_livestreams(channel : AboutChannel, continuation : String? = nil) + if continuation.nil? + # Fetch the first "page" of streams + items, next_continuation = get_livestreams(channel) + else + # Fetch a "page" of streams using the given continuation token + items, next_continuation = get_livestreams(channel, continuation: continuation) + end + + # If there is more to load, then load a second "page" + # and replace the previous continuation token + if !next_continuation.nil? + items_2, next_continuation = get_livestreams(channel, continuation: next_continuation) + items.concat items_2 + end + + return items, next_continuation + end end -- cgit v1.2.3 From 5d6abd5301b14c24475bf7ad477a43c60ff78993 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 1 Dec 2022 23:01:31 +0100 Subject: extractors: Fix ReelItemRendererParser --- src/invidious/yt_backend/extractors.cr | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index baf52118..bca0dcbd 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -382,7 +382,9 @@ private module Parsers end private def self.parse(item_contents, author_fallback) - return VideoRendererParser.process(item_contents, author_fallback) + child = VideoRendererParser.process(item_contents, author_fallback) + child ||= ReelItemRendererParser.process(item_contents, author_fallback) + return child end def self.parser_name @@ -406,12 +408,18 @@ private module Parsers private def self.parse(item_contents, author_fallback) video_id = item_contents["videoId"].as_s - video_details_container = item_contents.dig( - "navigationEndpoint", "reelWatchEndpoint", - "overlay", "reelPlayerOverlayRenderer", - "reelPlayerHeaderSupportedRenderers", - "reelPlayerHeaderRenderer" - ) + begin + video_details_container = item_contents.dig( + "navigationEndpoint", "reelWatchEndpoint", + "overlay", "reelPlayerOverlayRenderer", + "reelPlayerHeaderSupportedRenderers", + "reelPlayerHeaderRenderer" + ) + rescue ex : KeyError + # Extract key name from original message + key = /"([^"]+)"/.match(ex.message || "").try &.[1]? + raise BrokenTubeException.new(key || "reelPlayerOverlayRenderer") + end # Author infos @@ -434,9 +442,9 @@ private module Parsers # View count - view_count_text = video_details_container.dig?("viewCountText", "simpleText") - view_count_text ||= video_details_container - .dig?("viewCountText", "accessibility", "accessibilityData", "label") + # View count used to be in the reelWatchEndpoint, but that changed? + view_count_text = item_contents.dig?("viewCountText", "simpleText") + view_count_text ||= video_details_container.dig?("viewCountText", "simpleText") view_count = view_count_text.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 @@ -448,8 +456,8 @@ private module Parsers regex_match = /- (?\d+ minutes? )?(?\d+ seconds?)+ -/.match(a11y_data) - minutes = regex_match.try &.["min"].to_i(strict: false) || 0 - seconds = regex_match.try &.["sec"].to_i(strict: false) || 0 + minutes = regex_match.try &.["min"]?.try &.to_i(strict: false) || 0 + seconds = regex_match.try &.["sec"]?.try &.to_i(strict: false) || 0 duration = (minutes*60 + seconds) -- cgit v1.2.3 From 6c9754e66316d903ed4f89d2cd59cd82940509f5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 30 Nov 2022 00:29:48 +0100 Subject: frontend: Add support for shorts and livestreams --- locales/en-US.json | 9 ++++-- src/invidious/channels/about.cr | 10 +++++-- src/invidious/frontend/channel_page.cr | 43 +++++++++++++++++++++++++++ src/invidious/routes/channels.cr | 54 ++++++++++++++++++++++++++++++++-- src/invidious/routing.cr | 4 ++- src/invidious/views/channel.ecr | 30 +++++++------------ src/invidious/views/community.ecr | 15 ++-------- src/invidious/views/playlists.ecr | 14 +-------- 8 files changed, 124 insertions(+), 55 deletions(-) create mode 100644 src/invidious/frontend/channel_page.cr (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 5554b928..44b40c24 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -404,9 +404,7 @@ "`x` marked it with a ❤": "`x` marked it with a ❤", "Audio mode": "Audio mode", "Video mode": "Video mode", - "Videos": "Videos", "Playlists": "Playlists", - "Community": "Community", "search_filters_title": "Filters", "search_filters_date_label": "Upload date", "search_filters_date_option_none": "Any date", @@ -472,5 +470,10 @@ "crash_page_read_the_faq": "read the Frequently Asked Questions (FAQ)", "crash_page_search_issue": "searched for existing issues on GitHub", "crash_page_report_issue": "If none of the above helped, please open a new issue on GitHub (preferably in English) and include the following text in your message (do NOT translate that text):", - "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. Click here for the playlist home page." + "error_video_not_in_playlist": "The requested video doesn't exist in this playlist. Click here for the playlist home page.", + "channel_tab_videos_label": "Videos", + "channel_tab_shorts_label": "Shorts", + "channel_tab_streams_label": "Livestreams", + "channel_tab_playlists_label": "Playlists", + "channel_tab_community_label": "Community" } diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index bb9bd8c7..09c3427a 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -104,8 +104,14 @@ def get_about_info(ucid, locale) : AboutChannel if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]? # Get the name of the tabs available on this channel - tab_names = tabs_json.as_a - .compact_map(&.dig?("tabRenderer", "title").try &.as_s.downcase) + tab_names = tabs_json.as_a.compact_map do |entry| + name = entry.dig?("tabRenderer", "title").try &.as_s.downcase + + # This is a small fix to not add extra code on the HTML side + # I.e, the URL for the "live" tab is .../streams, so use "streams" + # everywhere for the sake of simplicity + (name == "live") ? "streams" : name + end # Get the currently active tab ("About") about_tab = extract_selected_tab(tabs_json) diff --git a/src/invidious/frontend/channel_page.cr b/src/invidious/frontend/channel_page.cr new file mode 100644 index 00000000..7ac0e071 --- /dev/null +++ b/src/invidious/frontend/channel_page.cr @@ -0,0 +1,43 @@ +module Invidious::Frontend::ChannelPage + extend self + + enum TabsAvailable + Videos + Shorts + Streams + Playlists + Community + end + + def generate_tabs_links(locale : String, channel : AboutChannel, selected_tab : TabsAvailable) + return String.build(1500) do |str| + base_url = "/channel/#{channel.ucid}" + + TabsAvailable.each do |tab| + # Ignore playlists, as it is not supported for auto-generated channels yet + next if (tab.playlists? && channel.auto_generated) + + tab_name = tab.to_s.downcase + + if channel.tabs.includes? tab_name + str << %(
    \n) + + if tab == selected_tab + str << "\t" + str << translate(locale, "channel_tab_#{tab_name}_label") + str << "\n" + else + # Video tab doesn't have the last path component + url = tab.videos? ? base_url : "#{base_url}/#{tab_name}" + + str << %(\t) + str << translate(locale, "channel_tab_#{tab_name}_label") + str << "\n" + end + + str << "
    " + end + end + end + end +end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 2773deb7..78b38341 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -18,7 +18,7 @@ module Invidious::Routes::Channels sort_options = {"last", "oldest", "newest"} sort_by ||= "last" - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + items, next_continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) items.uniq! do |item| if item.responds_to?(:title) item.title @@ -32,11 +32,59 @@ module Invidious::Routes::Channels sort_options = {"newest", "oldest", "popular"} sort_by ||= "newest" - items, continuation = Channel::Tabs.get_60_videos( + # Fetch items and continuation token + items, next_continuation = Channel::Tabs.get_videos( channel, continuation: continuation, sort_by: sort_by ) end + selected_tab = Frontend::ChannelPage::TabsAvailable::Videos + templated "channel" + end + + def self.shorts(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + if !channel.tabs.includes? "shorts" + return env.redirect "/channel/#{channel.ucid}" + end + + # TODO: support sort option for shorts + sort_by = "" + sort_options = [] of String + + # Fetch items and continuation token + items, next_continuation = Channel::Tabs.get_shorts( + channel, continuation: continuation + ) + + selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts + templated "channel" + end + + def self.streams(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + if !channel.tabs.includes? "streams" + return env.redirect "/channel/#{channel.ucid}" + end + + # TODO: support sort option for livestreams + sort_by = "" + sort_options = [] of String + + # Fetch items and continuation token + items, next_continuation = Channel::Tabs.get_60_livestreams( + channel, continuation: continuation + ) + + selected_tab = Frontend::ChannelPage::TabsAvailable::Streams templated "channel" end @@ -124,7 +172,7 @@ module Invidious::Routes::Channels end selected_tab = env.request.path.split("/")[-1] - if ["home", "videos", "playlists", "community", "channels", "about"].includes? selected_tab + if {"home", "videos", "shorts", "streams", "playlists", "community", "channels", "about"}.includes? selected_tab url = "/channel/#{ucid}/#{selected_tab}" else url = "/channel/#{ucid}" diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index f409f13c..08739c3d 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -115,6 +115,8 @@ module Invidious::Routing get "/channel/:ucid", Routes::Channels, :home get "/channel/:ucid/home", Routes::Channels, :home get "/channel/:ucid/videos", Routes::Channels, :videos + get "/channel/:ucid/shorts", Routes::Channels, :shorts + get "/channel/:ucid/streams", Routes::Channels, :streams get "/channel/:ucid/playlists", Routes::Channels, :playlists get "/channel/:ucid/community", Routes::Channels, :community get "/channel/:ucid/about", Routes::Channels, :about @@ -122,7 +124,7 @@ module Invidious::Routing get "/user/:user/live", Routes::Channels, :live get "/c/:user/live", Routes::Channels, :live - ["", "/videos", "/playlists", "/community", "/about"].each do |path| + {"", "/videos", "/shorts", "/streams", "/playlists", "/community", "/about"}.each do |path| # /c/LinusTechTips get "/c/:user#{path}", Routes::Channels, :brand_redirect # /user/linustechtips | Not always the same as /c/ diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 878587d4..f6cc3340 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -64,23 +64,8 @@ <%= translate(locale, "Switch Invidious Instance") %> <% end %>
    - <% if !channel.auto_generated %> -
    - <%= translate(locale, "Videos") %> -
    - <% end %> -
    - <% if channel.auto_generated %> - <%= translate(locale, "Playlists") %> - <% else %> - <%= translate(locale, "Playlists") %> - <% end %> -
    -
    - <% if channel.tabs.includes? "community" %> - <%= translate(locale, "Community") %> - <% end %> -
    + + <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %>
    @@ -111,7 +96,12 @@
    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 3bc29e55..e467a679 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -50,19 +50,8 @@ <%= translate(locale, "Switch Invidious Instance") %> <% end %>
    - <% if !channel.auto_generated %> - - <% end %> - -
    - <% if channel.tabs.includes? "community" %> - <%= translate(locale, "Community") %> - <% end %> -
    + + <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, Invidious::Frontend::ChannelPage::TabsAvailable::Community) %>
    diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr index c8718e7b..56d25ef5 100644 --- a/src/invidious/views/playlists.ecr +++ b/src/invidious/views/playlists.ecr @@ -54,19 +54,7 @@ <% end %>
    - -
    - <% if !channel.auto_generated %> - <%= translate(locale, "Playlists") %> - <% end %> -
    -
    - <% if channel.tabs.includes? "community" %> - <%= translate(locale, "Community") %> - <% end %> -
    + <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, Invidious::Frontend::ChannelPage::TabsAvailable::Playlists) %>
    -- cgit v1.2.3 From 40c666cab22693cf9d31895978ae4b4356e6579b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 4 Dec 2022 19:24:51 +0100 Subject: api: Add support for shorts and livestreams --- src/invidious/routes/api/v1/channels.cr | 114 ++++++++++++++++++++++++-------- src/invidious/routing.cr | 3 + 2 files changed, 90 insertions(+), 27 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 72d9ae5f..4e92b54e 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -1,11 +1,7 @@ module Invidious::Routes::API::V1::Channels - def self.home(env) - locale = env.get("preferences").as(Preferences).locale - - env.response.content_type = "application/json" - - ucid = env.params.url["ucid"] - + # Macro to avoid duplicating some code below + # This sets the `channel` variable, or handles Exceptions. + private macro get_channel begin channel = get_about_info(ucid, locale) rescue ex : ChannelRedirect @@ -16,6 +12,17 @@ module Invidious::Routes::API::V1::Channels rescue ex return error_json(500, ex) end + end + + def self.home(env) + locale = env.get("preferences").as(Preferences).locale + ucid = env.params.url["ucid"] + + env.response.content_type = "application/json" + + # Use the private macro defined above. + channel = nil # Make the compiler happy + get_channel() # Retrieve "sort by" setting from URL parameters sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" @@ -138,29 +145,89 @@ module Invidious::Routes::API::V1::Channels def self.videos(env) locale = env.get("preferences").as(Preferences).locale + ucid = env.params.url["ucid"] env.response.content_type = "application/json" + # Use the private macro defined above. + channel = nil # Make the compiler happy + get_channel() + + # Retrieve some URL parameters + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + continuation = env.params.query["continuation"]? + + begin + videos, next_continuation = Channel::Tabs.get_60_videos( + channel, continuation: continuation, sort_by: sort_by + ) + rescue ex + return error_json(500, ex) + end + + return JSON.build do |json| + json.object do + json.field "videos" do + json.array do + videos.each &.to_json(locale, json) + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.shorts(env) + locale = env.get("preferences").as(Preferences).locale ucid = env.params.url["ucid"] + env.response.content_type = "application/json" + + # Use the private macro defined above. + channel = nil # Make the compiler happy + get_channel() + + # Retrieve continuation from URL parameters + continuation = env.params.query["continuation"]? + begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex : NotFoundException - return error_json(404, ex) + videos, next_continuation = Channel::Tabs.get_shorts( + channel, continuation: continuation + ) rescue ex return error_json(500, ex) end - # Retrieve some URL parameters - sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + return JSON.build do |json| + json.object do + json.field "videos" do + json.array do + videos.each &.to_json(locale, json) + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.streams(env) + locale = env.get("preferences").as(Preferences).locale + ucid = env.params.url["ucid"] + + env.response.content_type = "application/json" + + # Use the private macro defined above. + channel = nil # Make the compiler happy + get_channel() + + # Retrieve continuation from URL parameters continuation = env.params.query["continuation"]? begin - videos, next_continuation = Channel::Tabs.get_60_videos( - channel, continuation: continuation, sort_by: sort_by + videos, next_continuation = Channel::Tabs.get_60_livestreams( + channel, continuation: continuation ) rescue ex return error_json(500, ex) @@ -190,16 +257,9 @@ module Invidious::Routes::API::V1::Channels env.params.query["sort_by"]?.try &.downcase || "last" - begin - channel = get_about_info(ucid, locale) - rescue ex : ChannelRedirect - env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) - return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) - rescue ex : NotFoundException - return error_json(404, ex) - rescue ex - return error_json(500, ex) - end + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 08739c3d..0e6fba21 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -222,6 +222,9 @@ module Invidious::Routing # Channels get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home + get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts + get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams + {% for route in {"videos", "latest", "playlists", "community", "search"} %} get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} -- cgit v1.2.3 From b6a4de66a5414f8ae790033fc3fc9e9fda70a860 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 4 Dec 2022 23:19:25 +0100 Subject: frontend: Unify the various channel pages --- src/invidious/routes/channels.cr | 33 +++++---- src/invidious/views/channel.ecr | 93 ++++++------------------ src/invidious/views/community.ecr | 65 ++++------------- src/invidious/views/components/channel_info.ecr | 60 ++++++++++++++++ src/invidious/views/playlists.ecr | 96 ------------------------- 5 files changed, 115 insertions(+), 232 deletions(-) create mode 100644 src/invidious/views/components/channel_info.ecr delete mode 100644 src/invidious/views/playlists.ecr (limited to 'src') diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 78b38341..77d309fb 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -7,18 +7,19 @@ module Invidious::Routes::Channels def self.videos(env) data = self.fetch_basic_information(env) - if !data.is_a?(Tuple) - return data - end + return data if !data.is_a?(Tuple) + locale, user, subscriptions, continuation, ucid, channel = data sort_by = env.params.query["sort_by"]?.try &.downcase if channel.auto_generated sort_options = {"last", "oldest", "newest"} - sort_by ||= "last" - items, next_continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + items, next_continuation = fetch_channel_playlists( + channel.ucid, channel.author, continuation, (sort_by || "last") + ) + items.uniq! do |item| if item.responds_to?(:title) item.title @@ -30,11 +31,10 @@ module Invidious::Routes::Channels items.each(&.author = "") else sort_options = {"newest", "oldest", "popular"} - sort_by ||= "newest" # Fetch items and continuation token items, next_continuation = Channel::Tabs.get_videos( - channel, continuation: continuation, sort_by: sort_by + channel, continuation: continuation, sort_by: (sort_by || "newest") ) end @@ -90,24 +90,26 @@ module Invidious::Routes::Channels def self.playlists(env) data = self.fetch_basic_information(env) - if !data.is_a?(Tuple) - return data - end + return data if !data.is_a?(Tuple) + locale, user, subscriptions, continuation, ucid, channel = data sort_options = {"last", "oldest", "newest"} sort_by = env.params.query["sort_by"]?.try &.downcase - sort_by ||= "last" if channel.auto_generated return env.redirect "/channel/#{channel.ucid}" end - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + items, next_continuation = fetch_channel_playlists( + channel.ucid, channel.author, continuation, (sort_by || "last") + ) + items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) items.each(&.author = "") - templated "playlists" + selected_tab = Frontend::ChannelPage::TabsAvailable::Playlists + templated "channel" end def self.community(env) @@ -121,12 +123,15 @@ module Invidious::Routes::Channels thin_mode = thin_mode == "true" continuation = env.params.query["continuation"]? - # sort_by = env.params.query["sort_by"]?.try &.downcase if !channel.tabs.includes? "community" return env.redirect "/channel/#{channel.ucid}" end + # TODO: support sort options for community posts + sort_by = "" + sort_options = [] of String + begin items = JSON.parse(fetch_channel_community(ucid, continuation, locale, "json", thin_mode)) rescue ex : InfoException diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index f6cc3340..039f8752 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -1,8 +1,23 @@ -<% ucid = channel.ucid %> -<% author = HTML.escape(channel.author) %> -<% channel_profile_pic = URI.parse(channel.author_thumbnail).request_target %> +<%- + ucid = channel.ucid + author = HTML.escape(channel.author) + channel_profile_pic = URI.parse(channel.author_thumbnail).request_target + + relative_url = + case selected_tab + when .shorts? then "/channel/#{ucid}/shorts" + when .streams? then "/channel/#{ucid}/streams" + when .playlists? then "/channel/#{ucid}/playlists" + else + "/channel/#{ucid}" + end + + youtube_url = "https://www.youtube.com#{relative_url}" + redirect_url = Invidious::Frontend::Misc.redirect_url(env) +-%> <% content_for "header" do %> +<%- if selected_tab.videos? -%> @@ -14,76 +29,14 @@ - -<%= author %> - Invidious -<% end %> - -<% if channel.banner %> -
    - "> -
    +<%- end -%> -
    -
    -
    + +<%= author %> - Invidious <% end %> -
    -
    -
    - - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> -
    -
    -
    -

    - -

    -
    -
    - -
    -
    -

    <%= channel.description_html %>

    -
    -
    - -
    - <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> -
    - -
    -
    - <%= translate(locale, "View channel on YouTube") %> -
    - <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> - "><%= translate(locale, "Switch Invidious Instance") %> - <% else %> - <%= translate(locale, "Switch Invidious Instance") %> - <% end %> -
    - - <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %> -
    -
    -
    -
    - <% sort_options.each do |sort| %> -
    - <% if sort_by == sort %> - <%= translate(locale, sort) %> - <% else %> - - <%= translate(locale, sort) %> - - <% end %> -
    - <% end %> -
    -
    -
    +<%= rendered "components/channel_info" %>

    @@ -99,7 +52,7 @@
    <% if next_continuation %> - &sort_by=<%= URI.encode_www_form(sort_by) %><% end %>"> + <%= translate(locale, "Next page") %> <% end %> diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index e467a679..9e11d562 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -1,60 +1,21 @@ -<% ucid = channel.ucid %> -<% author = HTML.escape(channel.author) %> +<%- + ucid = channel.ucid + author = HTML.escape(channel.author) + channel_profile_pic = URI.parse(channel.author_thumbnail).request_target -<% content_for "header" do %> -<%= author %> - Invidious -<% end %> + relative_url = "/channel/#{ucid}/community" + youtube_url = "https://www.youtube.com#{relative_url}" + redirect_url = Invidious::Frontend::Misc.redirect_url(env) -<% if channel.banner %> -
    - "> -
    + selected_tab = Invidious::Frontend::ChannelPage::TabsAvailable::Community +-%> -
    -
    -
    +<% content_for "header" do %> + +<%= author %> - Invidious <% end %> -
    -
    -
    - - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> -
    -
    -
    -

    - -

    -
    -
    - -
    -
    -

    <%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content %>

    -
    -
    - -
    - <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> -
    - -
    -
    - <%= translate(locale, "View channel on YouTube") %> -
    - <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> - "><%= translate(locale, "Switch Invidious Instance") %> - <% else %> - <%= translate(locale, "Switch Invidious Instance") %> - <% end %> -
    - - <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, Invidious::Frontend::ChannelPage::TabsAvailable::Community) %> -
    -
    -
    +<%= rendered "components/channel_info" %>

    diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr new file mode 100644 index 00000000..f216359f --- /dev/null +++ b/src/invidious/views/components/channel_info.ecr @@ -0,0 +1,60 @@ +<% if channel.banner %> +
    + "> +
    + +
    +
    +
    +<% end %> + +
    +
    +
    + + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> +
    +
    +
    +

    + +

    +
    +
    + +
    +
    +

    <%= channel.description_html %>

    +
    +
    + +
    + <% sub_count_text = number_to_short_text(channel.sub_count) %> + <%= rendered "components/subscribe_widget" %> +
    + +
    +
    + + + + <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, selected_tab) %> +
    +
    +
    + <% sort_options.each do |sort| %> +
    + <% if sort_by == sort %> + <%= translate(locale, sort) %> + <% else %> + <%= translate(locale, sort) %> + <% end %> +
    + <% end %> +
    +
    +
    diff --git a/src/invidious/views/playlists.ecr b/src/invidious/views/playlists.ecr deleted file mode 100644 index 56d25ef5..00000000 --- a/src/invidious/views/playlists.ecr +++ /dev/null @@ -1,96 +0,0 @@ -<% ucid = channel.ucid %> -<% author = HTML.escape(channel.author) %> - -<% content_for "header" do %> -<%= author %> - Invidious -<% end %> - -<% if channel.banner %> -
    - "> -
    - -
    -
    -
    -<% end %> - -
    -
    -
    - - <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %> -
    -
    -
    -

    - -

    -
    -
    - -
    -
    -

    <%= XML.parse_html(channel.description_html).xpath_node(%q(.//pre)).try &.content if !channel.description_html.empty? %>

    -
    -
    - -
    - <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> -
    - -
    -
    - - -
    - <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> - "><%= translate(locale, "Switch Invidious Instance") %> - <% else %> - <%= translate(locale, "Switch Invidious Instance") %> - <% end %> -
    - - <%= Invidious::Frontend::ChannelPage.generate_tabs_links(locale, channel, Invidious::Frontend::ChannelPage::TabsAvailable::Playlists) %> -
    -
    -
    -
    - <% {"last", "oldest", "newest"}.each do |sort| %> -
    - <% if sort_by == sort %> - <%= translate(locale, sort) %> - <% else %> - - <%= translate(locale, sort) %> - - <% end %> -
    - <% end %> -
    -
    -
    - -
    -
    -
    - -
    -<% items.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
    - - -- cgit v1.2.3 From 4e3a9306260b737e2d13c6a763899b946a6ecfbb Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 5 Dec 2022 00:50:04 +0100 Subject: frontend: Add support for the "featured channels" page --- locales/en-US.json | 3 +- src/invidious/channels/about.cr | 50 ++++++--------------------------- src/invidious/frontend/channel_page.cr | 1 + src/invidious/routes/api/v1/channels.cr | 24 ++-------------- src/invidious/routes/channels.cr | 20 +++++++++++++ src/invidious/routing.cr | 1 + src/invidious/views/channel.ecr | 1 + 7 files changed, 37 insertions(+), 63 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 44b40c24..12955665 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -475,5 +475,6 @@ "channel_tab_shorts_label": "Shorts", "channel_tab_streams_label": "Livestreams", "channel_tab_playlists_label": "Playlists", - "channel_tab_community_label": "Community" + "channel_tab_community_label": "Community", + "channel_tab_channels_label": "Channels" } diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 09c3427a..0054f8f2 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -16,12 +16,6 @@ record AboutChannel, tabs : Array(String), verified : Bool -record AboutRelatedChannel, - ucid : String, - author : String, - author_url : String, - author_thumbnail : String - def get_about_info(ucid, locale) : AboutChannel begin # "EgVhYm91dA==" is the base64-encoded protobuf object {"2:string":"about"} @@ -165,41 +159,15 @@ def get_about_info(ucid, locale) : AboutChannel ) end -def fetch_related_channels(about_channel : AboutChannel) : Array(AboutRelatedChannel) - # params is {"2:string":"channels"} encoded - channels = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D") - - tabs = channels.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs").try(&.as_a?) || [] of JSON::Any - tab = tabs.find(&.dig?("tabRenderer", "title").try(&.as_s?).try(&.== "Channels")) - - return [] of AboutRelatedChannel if tab.nil? - - items = tab.dig?( - "tabRenderer", "content", - "sectionListRenderer", "contents", 0, - "itemSectionRenderer", "contents", 0, - "gridRenderer", "items" - ).try &.as_a? - - related = [] of AboutRelatedChannel - return related if (items.nil? || items.empty?) - - items.each do |item| - renderer = item["gridChannelRenderer"]? - next if !renderer - - related_id = renderer.dig("channelId").as_s - related_title = renderer.dig("title", "simpleText").as_s - related_author_url = renderer.dig("navigationEndpoint", "browseEndpoint", "canonicalBaseUrl").as_s - related_author_thumbnail = HelperExtractors.get_thumbnails(renderer) - - related << AboutRelatedChannel.new( - ucid: related_id, - author: related_title, - author_url: related_author_url, - author_thumbnail: related_author_thumbnail, - ) +def fetch_related_channels(about_channel : AboutChannel, continuation : String? = nil) : {Array(SearchChannel), String?} + if continuation.nil? + # params is {"2:string":"channels"} encoded + initial_data = YoutubeAPI.browse(browse_id: about_channel.ucid, params: "EghjaGFubmVscw%3D%3D") + else + initial_data = YoutubeAPI.browse(continuation) end - return related + items, continuation = extract_items(initial_data) + + return items.select(SearchChannel), continuation end diff --git a/src/invidious/frontend/channel_page.cr b/src/invidious/frontend/channel_page.cr index 7ac0e071..53745dd5 100644 --- a/src/invidious/frontend/channel_page.cr +++ b/src/invidious/frontend/channel_page.cr @@ -7,6 +7,7 @@ module Invidious::Frontend::ChannelPage Streams Playlists Community + Channels end def generate_tabs_links(locale : String, channel : AboutChannel, selected_tab : TabsAvailable) diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 4e92b54e..28ccdab9 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -102,31 +102,13 @@ module Invidious::Routes::API::V1::Channels json.array do # Fetch related channels begin - related_channels = fetch_related_channels(channel) + related_channels, _ = fetch_related_channels(channel) rescue ex - related_channels = [] of AboutRelatedChannel + related_channels = [] of SearchChannel end related_channels.each do |related_channel| - json.object do - json.field "author", related_channel.author - json.field "authorId", related_channel.ucid - json.field "authorUrl", related_channel.author_url - - json.field "authorThumbnails" do - json.array do - qualities = {32, 48, 76, 100, 176, 512} - - qualities.each do |quality| - json.object do - json.field "url", related_channel.author_thumbnail.gsub(/=\d+/, "=s#{quality}") - json.field "width", quality - json.field "height", quality - end - end - end - end - end + related_channel.to_json(locale, json) end end end # relatedChannels diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 77d309fb..d3969d29 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -147,6 +147,26 @@ module Invidious::Routes::Channels templated "community" end + def self.channels(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + if channel.auto_generated + return env.redirect "/channel/#{channel.ucid}" + end + + items, next_continuation = fetch_related_channels(channel, continuation) + + # Featured/related channels can't be sorted + sort_options = [] of String + sort_by = nil + + selected_tab = Frontend::ChannelPage::TabsAvailable::Channels + templated "channel" + end + def self.about(env) data = self.fetch_basic_information(env) if !data.is_a?(Tuple) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 0e6fba21..84dbed5b 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -119,6 +119,7 @@ module Invidious::Routing get "/channel/:ucid/streams", Routes::Channels, :streams get "/channel/:ucid/playlists", Routes::Channels, :playlists get "/channel/:ucid/community", Routes::Channels, :community + get "/channel/:ucid/channels", Routes::Channels, :channels get "/channel/:ucid/about", Routes::Channels, :about get "/channel/:ucid/live", Routes::Channels, :live get "/user/:user/live", Routes::Channels, :live diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 039f8752..a29315ef 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -8,6 +8,7 @@ when .shorts? then "/channel/#{ucid}/shorts" when .streams? then "/channel/#{ucid}/streams" when .playlists? then "/channel/#{ucid}/playlists" + when .channels? then "/channel/#{ucid}/channels" else "/channel/#{ucid}" end -- cgit v1.2.3 From 69b8e0919fd0a410d35f5f5fccc4753f79faf940 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 22 Dec 2022 17:26:30 +0100 Subject: api: Add support for the "featured channels" endpoint --- src/invidious/routes/api/v1/channels.cr | 31 +++++++++++++++++++++++++++++++ src/invidious/routing.cr | 1 + 2 files changed, 32 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 28ccdab9..ca2b2734 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -283,6 +283,37 @@ module Invidious::Routes::API::V1::Channels end end + def self.channels(env) + locale = env.get("preferences").as(Preferences).locale + ucid = env.params.url["ucid"] + + env.response.content_type = "application/json" + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + continuation = env.params.query["continuation"]? + + begin + items, next_continuation = fetch_related_channels(channel, continuation) + rescue ex + return error_json(500, ex) + end + + JSON.build do |json| + json.object do + json.field "relatedChannels" do + json.array do + items.each &.to_json(locale, json) + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + def self.search(env) locale = env.get("preferences").as(Preferences).locale region = env.params.query["region"]? diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 84dbed5b..54bd82a4 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -225,6 +225,7 @@ module Invidious::Routing get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams + get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels {% for route in {"videos", "latest", "playlists", "community", "search"} %} get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} -- cgit v1.2.3 From f9eb839c7ae2c29e641495c4a2affd384445bf97 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 22 Dec 2022 13:05:13 +0100 Subject: channel: remove dead playlists code --- spec/invidious/helpers_spec.cr | 6 ---- src/invidious/channels/playlists.cr | 55 ------------------------------------- 2 files changed, 61 deletions(-) (limited to 'src') diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr index ab361770..f81cd29a 100644 --- a/spec/invidious/helpers_spec.cr +++ b/spec/invidious/helpers_spec.cr @@ -23,12 +23,6 @@ Spectator.describe "Helper" do end end - describe "#produce_channel_playlists_url" do - it "correctly produces a /browse_ajax URL with the given UCID and cursor" do - expect(produce_channel_playlists_url("UCCj956IF62FbT7Gouszaj9w", "AIOkY9EQpi_gyn1_QrFuZ1reN81_MMmI1YmlBblw8j7JHItEFG5h7qcJTNd4W9x5Quk_CVZ028gW")).to eq("/browse_ajax?continuation=4qmFsgLNARIYVUNDajk1NklGNjJGYlQ3R291c3phajl3GrABRWdsd2JHRjViR2x6ZEhNd0FqZ0JZQUZxQUxnQkFIcG1VVlZzVUdFeGF6VlNWa1ozWVZZNWJtVlhOSGhZTVVaNVVtNVdZVTFZU214VWFtZDRXREF4VG1KVmEzaFhWekZ6VVcxS2MyUjZhSEZPTUhCSlUxaFNSbEpyWXpGaFJHUjRXVEJ3VlZSdFVUQldlbXcwVGxaR01XRXhPVVJXYkc5M1RXcG9ibFozSUFFWUF3PT0%3D&gl=US&hl=en") - end - end - describe "#produce_comment_continuation" do it "correctly produces a continuation token for comments" do expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index 772eecb9..8dc824b2 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -26,58 +26,3 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) return extract_items(initial_data, author, ucid) end - -# ## NOTE: DEPRECATED -# Reason -> Unstable -# The Protobuf object must be provided with an id of the last playlist from the current "page" -# in order to fetch the next one accurately -# (if the id isn't included, entries shift around erratically between pages, -# leading to repetitions and skip overs) -# -# Since it's impossible to produce the appropriate Protobuf without an id being provided by the user, -# it's better to stick to continuation tokens provided by the first request and onward -def produce_channel_playlists_url(ucid, cursor, sort = "newest", auto_generated = false) - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:base64" => { - "2:string" => "playlists", - "6:varint" => 2_i64, - "7:varint" => 1_i64, - "12:varint" => 1_i64, - "13:string" => "", - "23:varint" => 0_i64, - }, - }, - } - - if cursor - cursor = Base64.urlsafe_encode(cursor, false) if !auto_generated - object["80226972:embedded"]["3:base64"].as(Hash)["15:string"] = cursor - end - - if auto_generated - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 0x32_i64 - else - object["80226972:embedded"]["3:base64"].as(Hash)["4:varint"] = 1_i64 - case sort - when "oldest", "oldest_created" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 2_i64 - when "newest", "newest_created" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 3_i64 - when "last", "last_added" - object["80226972:embedded"]["3:base64"].as(Hash)["3:varint"] = 4_i64 - else nil # Ignore - end - end - - object["80226972:embedded"]["3:string"] = Base64.urlsafe_encode(Protodec::Any.from_json(Protodec::Any.cast_json(object["80226972:embedded"]["3:base64"]))) - object["80226972:embedded"].delete("3:base64") - - continuation = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return "/browse_ajax?continuation=#{continuation}&gl=US&hl=en" -end -- cgit v1.2.3 From 865704dc7b0dee818b0f7636a085fcf1736635a7 Mon Sep 17 00:00:00 2001 From: confused_alex Date: Sun, 1 Jan 2023 19:41:58 +0100 Subject: Fixed dead link (#3526) --- src/invidious/views/user/data_control.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/user/data_control.ecr b/src/invidious/views/user/data_control.ecr index 74ccc06c..a451159f 100644 --- a/src/invidious/views/user/data_control.ecr +++ b/src/invidious/views/user/data_control.ecr @@ -14,7 +14,7 @@
    -- cgit v1.2.3 From 8df1c3bb57154dda021add8d11da9e7f4ae88bf1 Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Tue, 3 Jan 2023 10:17:47 -0500 Subject: Add support for timedtext captions --- src/invidious/routes/api/v1/videos.cr | 92 +++++++++++++++++++---------------- src/invidious/videos/caption.cr | 56 ++++++++++++++++++++- 2 files changed, 106 insertions(+), 42 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index a6b2eb4e..918fb421 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -90,47 +90,52 @@ module Invidious::Routes::API::V1::Videos # as well as some other markup that makes it cumbersome, so we try to fix that here if caption.name.includes? "auto-generated" caption_xml = YT_POOL.client &.get(url).body - caption_xml = XML.parse(caption_xml) - webvtt = String.build do |str| - str << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || caption.language_code} - - - END_VTT - - caption_nodes = caption_xml.xpath_nodes("//transcript/text") - caption_nodes.each_with_index do |node, i| - start_time = node["start"].to_f.seconds - duration = node["dur"]?.try &.to_f.seconds - duration ||= start_time - - if caption_nodes.size > i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration - end - - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - - text = HTML.unescape(node.content) - text = text.gsub(//, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?.*) : (?.*)/) - text = "#{md["text"]}" - end - - str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE - end - end + if caption_xml.starts_with?(" i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration + end + + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + + text = HTML.unescape(node.content) + text = text.gsub(//, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?.*) : (?.*)/) + text = "#{md["text"]}" + end + + str << <<-END_CUE + #{start_time} --> #{end_time} + #{text} + + + END_CUE + end + end + end else # Some captions have "align:[start/end]" and "position:[num]%" # attributes. Those are causing issues with VideoJS, which is unable @@ -138,7 +143,12 @@ module Invidious::Routes::API::V1::Videos # # See: https://github.com/iv-org/invidious/issues/2391 webvtt = YT_POOL.client &.get("#{url}&format=vtt").body - .gsub(/([0-9:.]{12} --> [0-9:.]{12}).+/, "\\1") + if webvtt.starts_with?(" [0-9:.]{12}).+/, "\\1") + end end if title = env.params.query["title"]? diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 4642c1a7..941b9646 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -30,7 +30,60 @@ module Invidious::Videos return captions_list end - + + def timedtext_to_vtt(timedtext : String, tlang = nil) : String + #In the future, we could just directly work with the url. This is more of a POC + cues = [] of XML::Node + tree = XML.parse(timedtext) + tree = tree.children.first() + + tree.children.each do |item| + if item.name == "body" + item.children.each do |cue| + if cue.name == "p" + cues << cue + end + end + break + end + end + result = String.build do |result| + result << <<-END_VTT + WEBVTT + Kind: captions + Language: #{tlang || @language_code} + + + END_VTT + cues.each_with_index do |node,i| + start_time = node["t"].to_f.milliseconds + + duration = node["d"]?.try &.to_f.milliseconds + + duration ||= start_time + + if cues.size > i + 1 + end_time = cues[i + 1]["t"].to_f.milliseconds + else + end_time = start_time + duration + end + + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + text = String.build do |text| + node.children.each do |s| + text << s.content + end + end + result << start_time + " --> " + end_time + "\n" + result << text + "\n" + result << "\n" + end + end + return result + end + # List of all caption languages available on Youtube. LANGUAGES = { "", @@ -164,5 +217,6 @@ module Invidious::Videos "Yoruba", "Zulu", } + end end -- cgit v1.2.3 From b49ed65a07d1e80eb5430b40dac47e7a2477cd39 Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Tue, 3 Jan 2023 10:21:16 -0500 Subject: Linting --- src/invidious/videos/caption.cr | 97 ++++++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 49 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 941b9646..4049c5d0 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -30,60 +30,60 @@ module Invidious::Videos return captions_list end - - def timedtext_to_vtt(timedtext : String, tlang = nil) : String - #In the future, we could just directly work with the url. This is more of a POC - cues = [] of XML::Node - tree = XML.parse(timedtext) - tree = tree.children.first() - - tree.children.each do |item| - if item.name == "body" - item.children.each do |cue| - if cue.name == "p" - cues << cue - end - end - break - end - end - result = String.build do |result| - result << <<-END_VTT + + def timedtext_to_vtt(timedtext : String, tlang = nil) : String + # In the future, we could just directly work with the url. This is more of a POC + cues = [] of XML::Node + tree = XML.parse(timedtext) + tree = tree.children.first + + tree.children.each do |item| + if item.name == "body" + item.children.each do |cue| + if cue.name == "p" + cues << cue + end + end + break + end + end + result = String.build do |result| + result << <<-END_VTT WEBVTT Kind: captions Language: #{tlang || @language_code} END_VTT - cues.each_with_index do |node,i| - start_time = node["t"].to_f.milliseconds - - duration = node["d"]?.try &.to_f.milliseconds - - duration ||= start_time - - if cues.size > i + 1 - end_time = cues[i + 1]["t"].to_f.milliseconds - else - end_time = start_time + duration - end - - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - text = String.build do |text| - node.children.each do |s| - text << s.content - end - end - result << start_time + " --> " + end_time + "\n" - result << text + "\n" - result << "\n" - end - end - return result - end - + cues.each_with_index do |node, i| + start_time = node["t"].to_f.milliseconds + + duration = node["d"]?.try &.to_f.milliseconds + + duration ||= start_time + + if cues.size > i + 1 + end_time = cues[i + 1]["t"].to_f.milliseconds + else + end_time = start_time + duration + end + + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + text = String.build do |text| + node.children.each do |s| + text << s.content + end + end + result << start_time + " --> " + end_time + "\n" + result << text + "\n" + result << "\n" + end + end + return result + end + # List of all caption languages available on Youtube. LANGUAGES = { "", @@ -217,6 +217,5 @@ module Invidious::Videos "Yoruba", "Zulu", } - end end -- cgit v1.2.3 From 45b8f6d0cd89541d93a479ec20f43ee5c029abf8 Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Tue, 3 Jan 2023 10:25:05 -0500 Subject: More linting --- src/invidious/routes/api/v1/videos.cr | 74 +++++++++++++++++------------------ 1 file changed, 37 insertions(+), 37 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 918fb421..eb371241 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -92,50 +92,50 @@ module Invidious::Routes::API::V1::Videos caption_xml = YT_POOL.client &.get(url).body if caption_xml.starts_with?(" i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration - end - - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - - text = HTML.unescape(node.content) - text = text.gsub(//, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?.*) : (?.*)/) - text = "#{md["text"]}" - end - - str << <<-END_CUE + + caption_nodes = caption_xml.xpath_nodes("//transcript/text") + caption_nodes.each_with_index do |node, i| + start_time = node["start"].to_f.seconds + duration = node["dur"]?.try &.to_f.seconds + duration ||= start_time + + if caption_nodes.size > i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration + end + + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + + text = HTML.unescape(node.content) + text = text.gsub(//, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?.*) : (?.*)/) + text = "#{md["text"]}" + end + + str << <<-END_CUE #{start_time} --> #{end_time} #{text} END_CUE - end - end - end + end + end + end else # Some captions have "align:[start/end]" and "position:[num]%" # attributes. Those are causing issues with VideoJS, which is unable @@ -144,11 +144,11 @@ module Invidious::Routes::API::V1::Videos # See: https://github.com/iv-org/invidious/issues/2391 webvtt = YT_POOL.client &.get("#{url}&format=vtt").body if webvtt.starts_with?(" [0-9:.]{12}).+/, "\\1") - end + webvtt = YT_POOL.client &.get("#{url}&format=vtt").body + .gsub(/([0-9:.]{12} --> [0-9:.]{12}).+/, "\\1") + end end if title = env.params.query["title"]? @@ -371,4 +371,4 @@ module Invidious::Routes::API::V1::Videos end end end -end +end \ No newline at end of file -- cgit v1.2.3 From 9d83e2da4e5c1dffc994dc8acd3f2a74280ffcc4 Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Tue, 3 Jan 2023 10:29:17 -0500 Subject: Add newline --- src/invidious/routes/api/v1/videos.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index eb371241..51344508 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -371,4 +371,4 @@ module Invidious::Routes::API::V1::Videos end end end -end \ No newline at end of file +end -- cgit v1.2.3 From 76758baab83b303e43a41a11bad37058c696905a Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Tue, 3 Jan 2023 13:10:26 -0500 Subject: Removed unneccesary String::Builder and removed cues that was just a blank line --- src/invidious/videos/caption.cr | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 4049c5d0..83a4c82f 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -41,7 +41,9 @@ module Invidious::Videos if item.name == "body" item.children.each do |cue| if cue.name == "p" - cues << cue + if !(cue.children.size == 1 && cue.children[0].content == "\n") + cues << cue + end end end break @@ -71,13 +73,13 @@ module Invidious::Videos start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - text = String.build do |text| - node.children.each do |s| - text << s.content - end - end + result << start_time + " --> " + end_time + "\n" - result << text + "\n" + + node.children.each do |s| + result << s.content + end + result << "\n" result << "\n" end end -- cgit v1.2.3 From 85dd3533bb4f9bc8e007d3b5de158f56db1445ce Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Tue, 3 Jan 2023 20:18:10 -0500 Subject: Fix for the ArithmeticOverflow Problem --- src/invidious/helpers/utils.cr | 2 +- src/invidious/yt_backend/extractors.cr | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index ed0cca38..59d8953a 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -162,7 +162,7 @@ def number_with_separator(number) end def short_text_to_number(short_text : String) : Int64 - matches = /(?\d+(\.\d+)?)\s?(?[mMkKbB])?/.match(short_text) + matches = /(?\d+(\.\d+)?)\s?(?[mMkKbB]|())?/.match(short_text) number = matches.try &.["number"].to_f || 0.0 case matches.try &.["suffix"].downcase diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index edc722cf..326d2d62 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -169,7 +169,12 @@ private module Parsers # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. # Always simpleText # TODO change default value to nil + subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") + if !subscriber_count || !subscriber_count.as_s.includes? " subscriber" + subscriber_count = item_contents.dig?("videoCountText", "simpleText") + end + subscriber_count = subscriber_count .try { |s| short_text_to_number(s.as_s.split(" ")[0]).to_i32 } || 0 # Auto-generated channels doesn't have videoCountText -- cgit v1.2.3 From 0d3610f63d726ac038861d3aede8d7339c552d74 Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Wed, 4 Jan 2023 18:12:15 -0500 Subject: Change regex used in short_text_to_number --- src/invidious/helpers/utils.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 59d8953a..72fdb187 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -162,7 +162,7 @@ def number_with_separator(number) end def short_text_to_number(short_text : String) : Int64 - matches = /(?\d+(\.\d+)?)\s?(?[mMkKbB]|())?/.match(short_text) + matches = /(?\d+(\.\d+)?)\s?(?[mMkKbB]?)/.match(short_text) number = matches.try &.["number"].to_f || 0.0 case matches.try &.["suffix"].downcase -- cgit v1.2.3 From 98301a223750b61915d61ac5221e8b71ea2b40ac Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Thu, 5 Jan 2023 23:08:05 +0000 Subject: Add ability to disable all user notifications (#3473) --- config/config.example.yml | 11 +++++++++ src/invidious/channels/channels.cr | 14 +++++++++-- src/invidious/config.cr | 2 ++ src/invidious/database/users.cr | 10 ++++++++ src/invidious/routes/embed.cr | 2 +- src/invidious/routes/feeds.cr | 38 ++++++++++++++++++----------- src/invidious/routes/watch.cr | 2 +- src/invidious/routing.cr | 10 +++++--- src/invidious/views/feeds/subscriptions.ecr | 4 +++ src/invidious/views/template.ecr | 4 ++- src/invidious/views/user/preferences.ecr | 2 ++ 11 files changed, 77 insertions(+), 22 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 8794880d..8abe1b9e 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -295,6 +295,17 @@ https_only: false ## #admins: [""] +## +## Enable/Disable the user notifications for all users +## +## Note: On large instances, it is recommended to set this option to 'false' +## in order to reduce the amount of data written to the database, and hence +## improve the overall performance of the instance. +## +## Accepted values: true, false +## Default: true +## +#enable_user_notifications: true # ----------------------------- # Background jobs diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index e3d3d9ee..9806d1da 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -228,7 +228,11 @@ def fetch_channel(ucid, pull_all_videos : Bool) if was_insert LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Inserted, updating subscriptions") - Invidious::Database::Users.add_notification(video) + if CONFIG.enable_user_notifications + Invidious::Database::Users.add_notification(video) + else + Invidious::Database::Users.feed_needs_update(video) + end else LOGGER.trace("fetch_channel: #{ucid} : video #{video_id} : Updated") end @@ -264,7 +268,13 @@ def fetch_channel(ucid, pull_all_videos : Bool) # so since they don't provide a published date here we can safely ignore them. if Time.utc - video.published > 1.minute was_insert = Invidious::Database::ChannelVideos.insert(video) - Invidious::Database::Users.add_notification(video) if was_insert + if was_insert + if CONFIG.enable_user_notifications + Invidious::Database::Users.add_notification(video) + else + Invidious::Database::Users.feed_needs_update(video) + end + end end end diff --git a/src/invidious/config.cr b/src/invidious/config.cr index c9bf43a4..9fc58409 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -110,6 +110,8 @@ class Config property hsts : Bool? = true # Disable proxying server-wide: options: 'dash', 'livestreams', 'downloads', 'local' property disable_proxy : Bool? | Array(String)? = false + # Enable the user notifications for all users + property enable_user_notifications : Bool = true # URL to the modified source code to be easily AGPL compliant # Will display in the footer, next to the main source code link diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr index f62b43ea..0a4a4fd8 100644 --- a/src/invidious/database/users.cr +++ b/src/invidious/database/users.cr @@ -154,6 +154,16 @@ module Invidious::Database::Users # Update (misc) # ------------------- + def feed_needs_update(video : ChannelVideo) + request = <<-SQL + UPDATE users + SET feed_needs_update = true + WHERE $1 = ANY(subscriptions) + SQL + + PG_DB.exec(request, video.ucid) + end + def update_preferences(user : User) request = <<-SQL UPDATE users diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 289d87c9..266f7ba4 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -147,7 +147,7 @@ module Invidious::Routes::Embed # PG_DB.exec("UPDATE users SET watched = array_append(watched, $1) WHERE email = $2", id, user.as(User).email) # end - if notifications && notifications.includes? id + if CONFIG.enable_user_notifications && notifications && notifications.includes? id Invidious::Database::Users.remove_notification(user.as(User), id) env.get("user").as(User).notifications.delete(id) notifications.delete(id) diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index b601db94..fb482e33 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -96,12 +96,14 @@ module Invidious::Routes::Feeds videos, notifications = get_subscription_feed(user, max_results, page) - # "updated" here is used for delivering new notifications, so if - # we know a user has looked at their feed e.g. in the past 10 minutes, - # they've already seen a video posted 20 minutes ago, and don't need - # to be notified. - Invidious::Database::Users.clear_notifications(user) - user.notifications = [] of String + if CONFIG.enable_user_notifications + # "updated" here is used for delivering new notifications, so if + # we know a user has looked at their feed e.g. in the past 10 minutes, + # they've already seen a video posted 20 minutes ago, and don't need + # to be notified. + Invidious::Database::Users.clear_notifications(user) + user.notifications = [] of String + end env.set "user", user templated "feeds/subscriptions" @@ -404,13 +406,15 @@ module Invidious::Routes::Feeds video = get_video(id, force_refresh: true) - # Deliver notifications to `/api/v1/auth/notifications` - payload = { - "topic" => video.ucid, - "videoId" => video.id, - "published" => published.to_unix, - }.to_json - PG_DB.exec("NOTIFY notifications, E'#{payload}'") + if CONFIG.enable_user_notifications + # Deliver notifications to `/api/v1/auth/notifications` + payload = { + "topic" => video.ucid, + "videoId" => video.id, + "published" => published.to_unix, + }.to_json + PG_DB.exec("NOTIFY notifications, E'#{payload}'") + end video = ChannelVideo.new({ id: id, @@ -426,7 +430,13 @@ module Invidious::Routes::Feeds }) was_insert = Invidious::Database::ChannelVideos.insert(video, with_premiere_timestamp: true) - Invidious::Database::Users.add_notification(video) if was_insert + if was_insert + if CONFIG.enable_user_notifications + Invidious::Database::Users.add_notification(video) + else + Invidious::Database::Users.feed_needs_update(video) + end + end end end diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 5f481557..5d3845c3 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -80,7 +80,7 @@ module Invidious::Routes::Watch Invidious::Database::Users.mark_watched(user.as(User), id) end - if notifications && notifications.includes? id + if CONFIG.enable_user_notifications && notifications && notifications.includes? id Invidious::Database::Users.remove_notification(user.as(User), id) env.get("user").as(User).notifications.delete(id) notifications.delete(id) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index f409f13c..1995677c 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -37,7 +37,9 @@ module Invidious::Routing get "/feed/webhook/:token", Routes::Feeds, :push_notifications_get post "/feed/webhook/:token", Routes::Feeds, :push_notifications_post - get "/modify_notifications", Routes::Notifications, :modify + if CONFIG.enable_user_notifications + get "/modify_notifications", Routes::Notifications, :modify + end {% end %} self.register_image_routes @@ -260,8 +262,10 @@ module Invidious::Routing post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token - get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + if CONFIG.enable_user_notifications + get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + end # Misc get "/api/v1/stats", {{namespace}}::Misc, :stats diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr index 8d56ad14..76f2f2bd 100644 --- a/src/invidious/views/feeds/subscriptions.ecr +++ b/src/invidious/views/feeds/subscriptions.ecr @@ -23,6 +23,8 @@
    +<% if CONFIG.enable_user_notifications %> +
    <%= translate_count(locale, "subscriptions_unseen_notifs_count", notifications.size) %>
    @@ -39,6 +41,8 @@ <% end %>
    +<% end %> +

    diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 98f72eba..77265679 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -54,7 +54,7 @@ + <% if CONFIG.enable_user_notifications %> <% end %> + <% end %> <% end %> <% if env.get?("user") && CONFIG.admins.includes? env.get?("user").as(Invidious::User).email %> -- cgit v1.2.3 From 8d08cfe30f550431015a7ecc8845b9c2968e27be Mon Sep 17 00:00:00 2001 From: DUO Labs Date: Thu, 5 Jan 2023 20:42:11 -0500 Subject: Add comments to src/invidious/yt_backend/extractors.cr Co-authored-by: Samantaz Fox --- src/invidious/yt_backend/extractors.cr | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 326d2d62..cd52c73b 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -171,6 +171,11 @@ private module Parsers # TODO change default value to nil subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") + + # Since youtube added channel handles, `VideoCountText` holds the number of + # subscribers and `subscriberCountText` holds the handle, except when the + # channel doesn't have a handle (e.g: some topic music channels). + # See https://github.com/iv-org/invidious/issues/3394#issuecomment-1321261688 if !subscriber_count || !subscriber_count.as_s.includes? " subscriber" subscriber_count = item_contents.dig?("videoCountText", "simpleText") end -- cgit v1.2.3 From a37522a03dc12f61386fc0529a9136ad296b1228 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 8 Jan 2023 13:50:52 +0100 Subject: Implement workaround for broken shorts objects --- src/invidious/channels/videos.cr | 30 ++++++++++++++++++++++++++---- src/invidious/exceptions.cr | 5 +++++ src/invidious/yt_backend/extractors.cr | 26 +++++++++++++++----------- 3 files changed, 46 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index bea406c1..befec03d 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -127,16 +127,38 @@ module Invidious::Channel::Tabs # Shorts # ------------------- - def get_shorts(channel : AboutChannel, continuation : String? = nil) + private def fetch_shorts_data(ucid : String, continuation : String? = nil) if continuation.nil? # EgZzaG9ydHPyBgUKA5oBAA%3D%3D is the protobuf object to load "shorts" # TODO: try to extract the continuation tokens that allows other sorting options - initial_data = YoutubeAPI.browse(channel.ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D") + return YoutubeAPI.browse(ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D") else - initial_data = YoutubeAPI.browse(continuation: continuation) + return YoutubeAPI.browse(continuation: continuation) end + end - return extract_items(initial_data, channel.author, channel.ucid) + def get_shorts(channel : AboutChannel, continuation : String? = nil) + initial_data = self.fetch_shorts_data(channel.ucid, continuation) + + begin + # Try to parse the initial data fetched above + return extract_items(initial_data, channel.author, channel.ucid) + rescue ex : RetryOnceException + # Sometimes, for a completely unknown reason, the "reelItemRenderer" + # object is missing some critical information (it happens once in about + # 20 subsequent requests). Refreshing the page is required to properly + # show the "shorts" tab. + # + # In order to make the experience smoother for the user, we simulate + # said page refresh by fetching again the JSON. If that still doesn't + # work, we raise a BrokenTubeException, as something is really broken. + begin + initial_data = self.fetch_shorts_data(channel.ucid, continuation) + return extract_items(initial_data, channel.author, channel.ucid) + rescue ex : RetryOnceException + raise BrokenTubeException.new "reelPlayerHeaderSupportedRenderers" + end + end end # ------------------- diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index 425c08da..690db907 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -33,3 +33,8 @@ end class VideoNotAvailableException < Exception end + +# Exception used to indicate that the JSON response from YT is missing +# some important informations, and that the query should be sent again. +class RetryOnceException < Exception +end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index bca0dcbd..65d107b2 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -408,19 +408,23 @@ private module Parsers private def self.parse(item_contents, author_fallback) video_id = item_contents["videoId"].as_s - begin - video_details_container = item_contents.dig( - "navigationEndpoint", "reelWatchEndpoint", - "overlay", "reelPlayerOverlayRenderer", - "reelPlayerHeaderSupportedRenderers", - "reelPlayerHeaderRenderer" - ) - rescue ex : KeyError - # Extract key name from original message - key = /"([^"]+)"/.match(ex.message || "").try &.[1]? - raise BrokenTubeException.new(key || "reelPlayerOverlayRenderer") + reel_player_overlay = item_contents.dig( + "navigationEndpoint", "reelWatchEndpoint", + "overlay", "reelPlayerOverlayRenderer" + ) + + # Sometimes, the "reelPlayerOverlayRenderer" object is missing the + # important part of the response. We use this exception to tell + # the calling function to fetch the content again. + if !reel_player_overlay.as_h.has_key?("reelPlayerHeaderSupportedRenderers") + raise RetryOnceException.new end + video_details_container = reel_player_overlay.dig( + "reelPlayerHeaderSupportedRenderers", + "reelPlayerHeaderRenderer" + ) + # Author infos author = video_details_container -- cgit v1.2.3 From 32471382c48289bafd0234d5e339fdfefb328da0 Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Sun, 8 Jan 2023 16:18:35 -0500 Subject: Different cosmetic fixes --- src/invidious/routes/api/v1/videos.cr | 6 +++--- src/invidious/videos/caption.cr | 34 +++++++++++++++++++++++----------- 2 files changed, 26 insertions(+), 14 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 51344508..54602112 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -128,11 +128,11 @@ module Invidious::Routes::API::V1::Videos end str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} + #{start_time} --> #{end_time} + #{text} - END_CUE + END_CUE end end end diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 83a4c82f..377f30d6 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -40,8 +40,7 @@ module Invidious::Videos tree.children.each do |item| if item.name == "body" item.children.each do |cue| - if cue.name == "p" - if !(cue.children.size == 1 && cue.children[0].content == "\n") + if cue.name == "p" && !(cue.children.size == 1 && cue.children[0].content == "\n") cues << cue end end @@ -51,12 +50,15 @@ module Invidious::Videos end result = String.build do |result| result << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || @language_code} - - - END_VTT + WEBVTT + Kind: captions + Language: #{tlang || @language_code} + + + END_VTT + + result << "\n\n" + cues.each_with_index do |node, i| start_time = node["t"].to_f.milliseconds @@ -70,11 +72,21 @@ module Invidious::Videos end_time = start_time + duration end - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + # start_time + result << start_time.hours.to_s.rjust(2, '0') + result << ':' << start_time.minutes.to_s.rjust(2, '0') + result << ':' << start_time.seconds.to_s.rjust(2, '0') + result << '.' << start_time.milliseconds.to_s.rjust(3, '0') - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" + result << " --> " - result << start_time + " --> " + end_time + "\n" + # end_time + result << end_time.hours.to_s.rjust(2, '0') + result << ':' << end_time.minutes.to_s.rjust(2, '0') + result << ':' << end_time.seconds.to_s.rjust(2, '0') + result << '.' << end_time.milliseconds.to_s.rjust(3, '0') + + result << "\n" node.children.each do |s| result << s.content -- cgit v1.2.3 From 4fc1b8ae86ab3d32955e72c957514799e5e121dc Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Sun, 8 Jan 2023 16:20:23 -0500 Subject: Remove superfluous 'end' --- src/invidious/videos/caption.cr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 377f30d6..95b9d643 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -42,7 +42,6 @@ module Invidious::Videos item.children.each do |cue| if cue.name == "p" && !(cue.children.size == 1 && cue.children[0].content == "\n") cues << cue - end end end break -- cgit v1.2.3 From 456e91426aeeafe889e2ea8887cfc3aa3f92fcd3 Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Sun, 8 Jan 2023 16:44:44 -0500 Subject: Formatting --- src/invidious/videos/caption.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 95b9d643..03bc3fd0 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -41,7 +41,7 @@ module Invidious::Videos if item.name == "body" item.children.each do |cue| if cue.name == "p" && !(cue.children.size == 1 && cue.children[0].content == "\n") - cues << cue + cues << cue end end break @@ -56,8 +56,8 @@ module Invidious::Videos END_VTT - result << "\n\n" - + result << "\n\n" + cues.each_with_index do |node, i| start_time = node["t"].to_f.milliseconds -- cgit v1.2.3 From 4b2d9420247ab83b2690a331c727e0227b5b7a19 Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Wed, 11 Jan 2023 15:58:07 -0500 Subject: Convert tabs to spaces --- src/invidious/routes/api/v1/videos.cr | 22 +++++++++++----------- src/invidious/videos/caption.cr | 8 ++++---- 2 files changed, 15 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 54602112..b10a30ea 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -98,12 +98,12 @@ module Invidious::Routes::API::V1::Videos webvtt = String.build do |str| str << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || caption.language_code} - - - END_VTT + WEBVTT + Kind: captions + Language: #{tlang || caption.language_code} + + + END_VTT caption_nodes = caption_xml.xpath_nodes("//transcript/text") caption_nodes.each_with_index do |node, i| @@ -128,11 +128,11 @@ module Invidious::Routes::API::V1::Videos end str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE + #{start_time} --> #{end_time} + #{text} + + + END_CUE end end end diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 03bc3fd0..13f81a31 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -49,12 +49,12 @@ module Invidious::Videos end result = String.build do |result| result << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || @language_code} + WEBVTT + Kind: captions + Language: #{tlang || @language_code} - END_VTT + END_VTT result << "\n\n" -- cgit v1.2.3 From 1fb0a495925a60b2d4839b98440e220b7f95d10e Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Fri, 13 Jan 2023 12:05:01 -0500 Subject: Make DASH absolute urls when local --- src/invidious/routes/api/manifest.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index ae65f10d..f5d8e5de 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -42,7 +42,7 @@ module Invidious::Routes::API::Manifest if local adaptive_fmts.each do |fmt| - fmt["url"] = JSON::Any.new(URI.parse(fmt["url"].as_s).request_target) + fmt["url"] = JSON::Any.new("#{HOST_URL}#{URI.parse(fmt["url"].as_s).request_target}") end end -- cgit v1.2.3 From 01acb9bfbfda00c4fdcb8de87c33174d694de530 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Fri, 13 Jan 2023 19:04:37 -0500 Subject: Login redirect to referer on logged-in user --- src/invidious/helpers/utils.cr | 2 +- src/invidious/routes/login.cr | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index ed0cca38..4448508c 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -259,7 +259,7 @@ def get_referer(env, fallback = "/", unroll = true) end referer = referer.request_target - referer = "/" + referer.gsub(/[^\/?@&%=\-_.0-9a-zA-Z]/, "").lstrip("/\\") + referer = "/" + referer.gsub(/[^\/?@&%=\-_.:,0-9a-zA-Z]/, "").lstrip("/\\") if referer == env.request.path referer = fallback diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 99fc13a2..6454131a 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -6,14 +6,14 @@ module Invidious::Routes::Login user = env.get? "user" - return env.redirect "/feed/subscriptions" if user + referer = get_referer(env, "/feed/subscriptions") + + return env.redirect referer if user if !CONFIG.login_enabled return error_template(400, "Login has been disabled by administrator.") end - referer = get_referer(env, "/feed/subscriptions") - email = nil password = nil captcha = nil -- cgit v1.2.3 From 1b5fbfc13efa9eace904d24dc89b7fdf72c1ce52 Mon Sep 17 00:00:00 2001 From: techmetx11 Date: Sat, 14 Jan 2023 09:38:55 +0100 Subject: Video: Add support for the music section --- locales/en-US.json | 3 +++ src/invidious/videos.cr | 3 +++ src/invidious/videos/parser.cr | 22 ++++++++++++++++++++++ src/invidious/views/watch.ecr | 9 +++++++++ 4 files changed, 37 insertions(+) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 12955665..bc6a3275 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -188,6 +188,9 @@ "Engagement: ": "Engagement: ", "Whitelisted regions: ": "Whitelisted regions: ", "Blacklisted regions: ": "Blacklisted regions: ", + "Music artist: ": "Music artist: ", + "Music album: ": "Music album: ", + "Music licenses: ": "Music licenses: ", "Shared `x`": "Shared `x`", "Premieres in `x`": "Premieres in `x`", "Premieres `x`": "Premieres `x`", diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index d626c7d1..be4854fe 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -314,6 +314,9 @@ struct Video getset_string genre getset_string genreUcid getset_string license + getset_string music_artist + getset_string music_album + getset_string music_licenses getset_string shortDescription getset_string subCountText getset_string title diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 5df49286..4540dd13 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -309,6 +309,24 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any end end + # Music section + + music_desc = player_response.dig?("engagementPanels", 1, "engagementPanelSectionListRenderer", "content", "structuredDescriptionContentRenderer", "items", 2, "videoDescriptionMusicSectionRenderer", "carouselLockups", 0, "carouselLockupRenderer", "infoRows").try &.as_a + artist = nil + album = nil + music_licenses = nil + + music_desc.try &.each do |desc| + desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) + if desc_title == "ARTIST" + artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "ALBUM" + album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "LICENSES" + music_licenses = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) + end + end + # Author infos author = video_details["author"]?.try &.as_s @@ -359,6 +377,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any "genre" => JSON::Any.new(genre.try &.as_s || ""), "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), "license" => JSON::Any.new(license.try &.as_s || ""), + # Music section + "music_artist" => JSON::Any.new(artist || ""), + "music_album" => JSON::Any.new(album || ""), + "music_licenses" => JSON::Any.new(music_licenses || ""), # Author infos "author" => JSON::Any.new(author || ""), "ucid" => JSON::Any.new(ucid || ""), diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index a6f2e524..beab1bb2 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -196,6 +196,15 @@ we're going to need to do it here in order to allow for translations. <% end %>

    <% end %> + <% if !video.music_artist.empty? %> +

    <%= translate(locale, "Music artist: ") %><%= video.music_artist %>

    + <% end %> + <% if !video.music_album.empty? %> +

    <%= translate(locale, "Music album: ") %><%= video.music_album %>

    + <% end %> + <% if !video.music_licenses.empty? %> +

    <%= translate(locale, "Music licenses: ") %><%= video.music_licenses %>

    + <% end %>
    -- cgit v1.2.3 From 4ee483282e072473b618df1ce9a96668c2905cf5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 14 Jan 2023 20:00:46 +0100 Subject: Video proxy: always include the 'range' header --- src/invidious/routes/video_playback.cr | 10 ++++++++++ 1 file changed, 10 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 560f9c19..a0216cce 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -35,6 +35,13 @@ module Invidious::Routes::VideoPlayback end end + # See: https://github.com/iv-org/invidious/issues/3302 + range_header = env.request.headers["Range"]? + if range_header.nil? + range_for_head = query_params["range"]? || "0-640" + headers["Range"] = "bytes=#{range_for_head}" + end + client = make_client(URI.parse(host), region) response = HTTP::Client::Response.new(500) error = "" @@ -70,6 +77,9 @@ module Invidious::Routes::VideoPlayback end end + # Remove the Range header added previously. + headers.delete("Range") if range_header.nil? + if response.status_code >= 400 env.response.content_type = "text/plain" haltf env, response.status_code -- cgit v1.2.3 From d6087fac472711376762cfb2c5f7672f84f6d4fe Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Sun, 15 Jan 2023 12:07:58 +0000 Subject: Don't continue when LOGIN_REQUIRED and no videoDetails --- src/invidious/videos/parser.cr | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 5df49286..5c323975 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -66,8 +66,10 @@ def extract_video_info(video_id : String, proxy_region : String? = nil) reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") reason ||= player_response.dig("playabilityStatus", "reason").as_s - # Stop here if video is not a scheduled livestream - if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) + # Stop here if video is not a scheduled livestream or + # for LOGIN_REQUIRED when videoDetails element is not found because retrying won't help + if !{"LIVE_STREAM_OFFLINE", "LOGIN_REQUIRED"}.any?(playability_status) || + playability_status == "LOGIN_REQUIRED" && !player_response.dig?("videoDetails") return { "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), "reason" => JSON::Any.new(reason), -- cgit v1.2.3 From 1af846e58c3df98a589fe8fdda3e45f2745e69bc Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 15 Jan 2023 17:04:04 +0100 Subject: API: make /api/v1/videos respect the 'local' parameter --- src/invidious.cr | 1 + src/invidious/http_server/utils.cr | 20 ++++++++++++++++++++ src/invidious/jsonify/api_v1/video_json.cr | 11 +++++++++-- src/invidious/routes/api/v1/videos.cr | 5 ++++- src/invidious/routes/video_playback.cr | 10 ++-------- 5 files changed, 36 insertions(+), 11 deletions(-) create mode 100644 src/invidious/http_server/utils.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 5064f0b8..d4f8e0fb 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -34,6 +34,7 @@ require "protodec/utils" require "./invidious/database/*" require "./invidious/database/migrations/*" +require "./invidious/http_server/*" require "./invidious/helpers/*" require "./invidious/yt_backend/*" require "./invidious/frontend/*" diff --git a/src/invidious/http_server/utils.cr b/src/invidious/http_server/utils.cr new file mode 100644 index 00000000..e3f1fa0f --- /dev/null +++ b/src/invidious/http_server/utils.cr @@ -0,0 +1,20 @@ +module Invidious::HttpServer + module Utils + extend self + + def proxy_video_url(raw_url : String, *, region : String? = nil, absolute : Bool = false) + url = URI.parse(raw_url) + + # Add some URL parameters + params = url.query_params + params["host"] = url.host.not_nil! # Should never be nil, in theory + params["region"] = region if !region.nil? + + if absolute + return "#{HOST_URL}#{url.request_target}?#{params}" + else + return "#{url.request_target}?#{params}" + end + end + end +end diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 642789aa..a2b1a35c 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -3,7 +3,7 @@ require "json" module Invidious::JSONify::APIv1 extend self - def video(video : Video, json : JSON::Builder, *, locale : String?) + def video(video : Video, json : JSON::Builder, *, locale : String?, proxy : Bool = false) json.object do json.field "type", video.video_type @@ -89,7 +89,14 @@ module Invidious::JSONify::APIv1 # Not available on MPEG-4 Timed Text (`text/mp4`) streams (livestreams only) json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? - json.field "url", fmt["url"] + if proxy + json.field "url", Invidious::HttpServer::Utils.proxy_video_url( + fmt["url"].to_s, absolute: true + ) + else + json.field "url", fmt["url"] + end + json.field "itag", fmt["itag"].as_i.to_s json.field "type", fmt["mimeType"] json.field "clen", fmt["contentLength"]? || "-1" diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index a6b2eb4e..79f7bd3f 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -6,6 +6,7 @@ module Invidious::Routes::API::V1::Videos id = env.params.url["id"] region = env.params.query["region"]? + proxy = {"1", "true"}.any? &.== env.params.query["local"]? begin video = get_video(id, region: region) @@ -15,7 +16,9 @@ module Invidious::Routes::API::V1::Videos return error_json(500, ex) end - video.to_json(locale, nil) + return JSON.build do |json| + Invidious::JSONify::APIv1.video(video, json, locale: locale, proxy: proxy) + end end def self.captions(env) diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 560f9c19..04b13630 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -91,14 +91,8 @@ module Invidious::Routes::VideoPlayback env.response.headers["Access-Control-Allow-Origin"] = "*" if location = resp.headers["Location"]? - location = URI.parse(location) - location = "#{location.request_target}&host=#{location.host}" - - if region - location += "®ion=#{region}" - end - - return env.redirect location + url = Invidious::HttpServer::Utils.proxy_video_url(location, region: region) + return env.redirect url end IO.copy(resp.body_io, env.response) -- cgit v1.2.3 From fe5b81f2c3caf37e10fd3284a49146e7aefb1530 Mon Sep 17 00:00:00 2001 From: techmetx11 Date: Mon, 16 Jan 2023 13:58:05 +0100 Subject: Add support for multiple songs --- assets/css/default.css | 35 +++++++++++++++++++++++++---------- locales/en-US.json | 5 ++--- src/invidious/videos.cr | 12 +++++++++--- src/invidious/videos/music.cr | 12 ++++++++++++ src/invidious/videos/parser.cr | 35 +++++++++++++++++++---------------- src/invidious/views/watch.ecr | 42 +++++++++++++++++++++++++++++++----------- 6 files changed, 98 insertions(+), 43 deletions(-) create mode 100644 src/invidious/videos/music.cr (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 80bf6a20..4ec6f720 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -490,26 +490,31 @@ hr { } /* Description Expansion Styling*/ -#descexpansionbutton { +#descexpansionbutton, +#musicdescexpansionbutton { display: none } -#descexpansionbutton ~ div { +#descexpansionbutton ~ div, +#musicdescexpansionbutton ~ div { overflow: hidden; height: 8.3em; } -#descexpansionbutton:checked ~ div { +#descexpansionbutton:checked ~ div, +#musicdescexpansionbutton:checked ~ div { overflow: unset; height: 100%; } -#descexpansionbutton ~ label { +#descexpansionbutton ~ label, +#musicdescexpansionbutton ~ label { order: 1; margin-top: 20px; } -label[for="descexpansionbutton"]:hover { +label[for="descexpansionbutton"]:hover, +label[for="musicdescexpansionbutton"]:hover { cursor: pointer; } @@ -521,14 +526,24 @@ h4, h5, p, #descriptionWrapper, -#description-box { - unicode-bidi: plaintext; - text-align: start; +#description-box, +#music-description-box, +#musicDescriptionWrapper { + unicode-bidi: plaintext; + text-align: start; } #descriptionWrapper { - max-width: 600px; - white-space: pre-wrap; + max-width: 600px; + white-space: pre-wrap; +} + +#musicDescriptionWrapper { + max-width: 600px; +} + +#music-description-title { + margin-bottom: 0px; } /* Center the "invidious" logo on the search page */ diff --git a/locales/en-US.json b/locales/en-US.json index bc6a3275..20f1a46d 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -188,9 +188,8 @@ "Engagement: ": "Engagement: ", "Whitelisted regions: ": "Whitelisted regions: ", "Blacklisted regions: ": "Blacklisted regions: ", - "Music artist: ": "Music artist: ", - "Music album: ": "Music album: ", - "Music licenses: ": "Music licenses: ", + "Artist: ": "Artist: ", + "Album: ": "Album: ", "Shared `x`": "Shared `x`", "Premieres in `x`": "Premieres in `x`", "Premieres `x`": "Premieres `x`", diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index be4854fe..aa3ef1a8 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -247,6 +247,15 @@ struct Video info["reason"]?.try &.as_s end + def music : Array(VideoMusic) + music_list = Array(VideoMusic).new + + info["music"].as_a.each do |music_json| + music_list << VideoMusic.new(music_json["album"].as_s, music_json["artist"].as_s, music_json["license"].as_s) + end + return music_list + end + # Macros defining getters/setters for various types of data private macro getset_string(name) @@ -314,9 +323,6 @@ struct Video getset_string genre getset_string genreUcid getset_string license - getset_string music_artist - getset_string music_album - getset_string music_licenses getset_string shortDescription getset_string subCountText getset_string title diff --git a/src/invidious/videos/music.cr b/src/invidious/videos/music.cr new file mode 100644 index 00000000..402ae46f --- /dev/null +++ b/src/invidious/videos/music.cr @@ -0,0 +1,12 @@ +require "json" + +struct VideoMusic + include JSON::Serializable + + property album : String + property artist : String + property license : String + + def initialize(@album : String, @artist : String, @license : String) + end +end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 4540dd13..69b04cb6 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -311,20 +311,25 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # Music section - music_desc = player_response.dig?("engagementPanels", 1, "engagementPanelSectionListRenderer", "content", "structuredDescriptionContentRenderer", "items", 2, "videoDescriptionMusicSectionRenderer", "carouselLockups", 0, "carouselLockupRenderer", "infoRows").try &.as_a - artist = nil - album = nil - music_licenses = nil - - music_desc.try &.each do |desc| - desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) - if desc_title == "ARTIST" - artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) - elsif desc_title == "ALBUM" - album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) - elsif desc_title == "LICENSES" - music_licenses = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) + music_list = [] of VideoMusic + music_desclist = player_response.dig?("engagementPanels", 1, "engagementPanelSectionListRenderer", "content", "structuredDescriptionContentRenderer", "items", 2, "videoDescriptionMusicSectionRenderer", "carouselLockups").try &.as_a + music_desclist.try &.each do |music_desc| + artist = nil + album = nil + music_license = nil + + music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.try &.each do |desc| + desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) + if desc_title == "ARTIST" + artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "ALBUM" + album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "LICENSES" + music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) + end end + music = VideoMusic.new(album.to_s, artist.to_s, music_license.to_s) + music_list << music end # Author infos @@ -378,9 +383,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), "license" => JSON::Any.new(license.try &.as_s || ""), # Music section - "music_artist" => JSON::Any.new(artist || ""), - "music_album" => JSON::Any.new(album || ""), - "music_licenses" => JSON::Any.new(music_licenses || ""), + "music" => JSON.parse(music_list.to_json), # Author infos "author" => JSON::Any.new(author || ""), "ucid" => JSON::Any.new(ucid || ""), diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index beab1bb2..207dae18 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -34,11 +34,13 @@ we're going to need to do it here in order to allow for translations. --> @@ -196,15 +198,6 @@ we're going to need to do it here in order to allow for translations. <% end %>

    <% end %> - <% if !video.music_artist.empty? %> -

    <%= translate(locale, "Music artist: ") %><%= video.music_artist %>

    - <% end %> - <% if !video.music_album.empty? %> -

    <%= translate(locale, "Music album: ") %><%= video.music_album %>

    - <% end %> - <% if !video.music_licenses.empty? %> -

    <%= translate(locale, "Music licenses: ") %><%= video.music_licenses %>

    - <% end %>
    @@ -244,6 +237,33 @@ we're going to need to do it here in order to allow for translations.
    + <% if !video.music.empty? %> +

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

    +
    + <% if video.music.size == 1 %> +
    +

    <%= translate(locale, "Artist: ") %><%= video.music[0].artist %>

    +

    <%= translate(locale, "Album: ") %><%= video.music[0].album %>

    +

    <%= translate(locale, "License: ") %><%= video.music[0].license %>

    +
    + <% else %> + +
    + <% video.music.each do |music| %> +

    <%= translate(locale, "Artist: ") %><%= music.artist %>

    +

    <%= translate(locale, "Album: ") %><%= music.album %>

    +

    <%= translate(locale, "License: ") %><%= music.license %>

    +
    + <% end %> +
    + + <% end %> +
    +
    + + <% end %>
    <% if nojs %> <%= comment_html %> -- cgit v1.2.3 From 910809f1eb185328fa94b5d8baff9ba7756ade48 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Mon, 16 Jan 2023 08:33:34 -0500 Subject: Handle case with included manifest --- src/invidious/routes/api/manifest.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index f5d8e5de..662d1002 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -29,7 +29,7 @@ module Invidious::Routes::API::Manifest if local uri = URI.parse(url) - url = "#{uri.request_target}host/#{uri.host}/" + url = "#{HOST_URL}#{uri.request_target}host/#{uri.host}/" end "#{url}" -- cgit v1.2.3 From 8dcc98b3b9d8e189a4c92ab0cbed7e3635341b5d Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Mon, 16 Jan 2023 18:37:52 -0500 Subject: If videCountText lists the number of subscribers, then don't use it in get_video_count --- src/invidious/yt_backend/extractors.cr | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index cd52c73b..d32f5646 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -652,8 +652,13 @@ module HelperExtractors # # Returns a 0 when it's unable to do so def self.get_video_count(container : JSON::Any) : Int32 + puts container if box = container["videoCountText"]? - return extract_text(box).try &.gsub(/\D/, "").to_i || 0 + if (extracted_text = extract_text(box)) && !extracted_text.includes? " subscriber" + return extracted_text.gsub(/\D/, "").to_i + else + return 0 + end elsif box = container["videoCount"]? return box.as_s.to_i else -- cgit v1.2.3 From 855202e40e09af1cb5fb372d4a2d05a61b3a9bb2 Mon Sep 17 00:00:00 2001 From: Gavin Johnson Date: Mon, 16 Jan 2023 15:40:38 -0800 Subject: added youtube playlist import; initial commit Signed-off-by: Gavin Johnson --- locales/en-US.json | 1 + src/invidious/user/imports.cr | 85 +++++++++++++++++++++++++++++++ src/invidious/views/user/data_control.ecr | 5 ++ 3 files changed, 91 insertions(+) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 12955665..c30a90db 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -33,6 +33,7 @@ "Import": "Import", "Import Invidious data": "Import Invidious JSON data", "Import YouTube subscriptions": "Import YouTube/OPML subscriptions", + "Import YouTube playlist": "Import YouTube playlist (.csv)", "Import FreeTube subscriptions (.db)": "Import FreeTube subscriptions (.db)", "Import NewPipe subscriptions (.json)": "Import NewPipe subscriptions (.json)", "Import NewPipe data (.zip)": "Import NewPipe data (.zip)", diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 20ae0d47..870d083e 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -30,6 +30,75 @@ struct Invidious::User return subscriptions end + # Parse a youtube CSV playlist file and create the playlist + #NEW - Done + def parse_playlist_export_csv(user : User, csv_content : String) + rows = CSV.new(csv_content, headers: false) + if rows.size >= 2 + title = rows[1][4]?.try &.as_s?.try + descripton = rows[1][5]?.try &.as_s?.try + visibility = rows[1][6]?.try &.as_s?.try + + if visibility.compare("Public", case_insensitive: true) == 0 + privacy = PlaylistPrivacy:Public + else + privacy = PlaylistPrivacy:Private + end + + if title && privacy && user + playlist = create_playlist(title, privacy, user) + end + + if playlist && descripton + Invidious::Database::Playlists.update_description(playlist.id, description) + end + end + + return playlist + end + + # Parse a youtube CSV playlist file and add videos from it to a playlist + #NEW - done + def parse_playlist_videos_export_csv(playlist : Playlist, csv_content : String) + rows = CSV.new(csv_content, headers: false) + if rows.size >= 5 + offset = env.params.query["index"]?.try &.to_i? || 0 + row_counter = 0 + rows.each do |row| + if row_counter >= 4 + video_id = row[0]?.try &.as_s?.try + end + row_counter += 1 + next if !video_id + + begin + video = get_video(video_id) + rescue ex + next + 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), + }) + + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) + end + + videos = get_playlist_videos(playlist, offset: offset) + end + + return videos + end + # ------------------- # Invidious # ------------------- @@ -149,6 +218,22 @@ struct Invidious::User return true end + # Import playlist from Youtube + # Returns success status + #NEW + def from_youtube_pl(user : User, body : String, filename : String, type : String) : Bool + extension = filename.split(".").last + + if extension == "csv" || type == "text/csv" + playlist = parse_playlist_export_csv(user, body) + playlist = parse_playlist_videos_export_csv(playlist, body) + else + return false + end + + return true + end + # ------------------- # Freetube # ------------------- diff --git a/src/invidious/views/user/data_control.ecr b/src/invidious/views/user/data_control.ecr index a451159f..0f8e8dae 100644 --- a/src/invidious/views/user/data_control.ecr +++ b/src/invidious/views/user/data_control.ecr @@ -21,6 +21,11 @@
    +
    + + +
    +
    -- cgit v1.2.3 From 86333cd4344267f09ca34a179558dc71fb8b6fb4 Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Mon, 16 Jan 2023 18:43:58 -0500 Subject: Formatting --- src/invidious/yt_backend/extractors.cr | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index d32f5646..fbb02824 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -652,13 +652,13 @@ module HelperExtractors # # Returns a 0 when it's unable to do so def self.get_video_count(container : JSON::Any) : Int32 - puts container + puts container if box = container["videoCountText"]? - if (extracted_text = extract_text(box)) && !extracted_text.includes? " subscriber" - return extracted_text.gsub(/\D/, "").to_i - else - return 0 - end + if (extracted_text = extract_text(box)) && !extracted_text.includes? " subscriber" + return extracted_text.gsub(/\D/, "").to_i + else + return 0 + end elsif box = container["videoCount"]? return box.as_s.to_i else -- cgit v1.2.3 From 67ace4fd9dd1a62ab004b05dc0403cc71ef5e206 Mon Sep 17 00:00:00 2001 From: DUO Labs Date: Mon, 16 Jan 2023 18:50:38 -0500 Subject: Some indention changes Co-authored-by: Samantaz Fox --- src/invidious/routes/api/v1/videos.cr | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index b10a30ea..4ef877e5 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -98,12 +98,12 @@ module Invidious::Routes::API::V1::Videos webvtt = String.build do |str| str << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || caption.language_code} - - - END_VTT + WEBVTT + Kind: captions + Language: #{tlang || caption.language_code} + + + END_VTT caption_nodes = caption_xml.xpath_nodes("//transcript/text") caption_nodes.each_with_index do |node, i| @@ -128,11 +128,11 @@ module Invidious::Routes::API::V1::Videos end str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE + #{start_time} --> #{end_time} + #{text} + + + END_CUE end end end -- cgit v1.2.3 From ff66cec9209f464ffc269a7d189199a22b5486c0 Mon Sep 17 00:00:00 2001 From: DUOLabs333 Date: Mon, 16 Jan 2023 18:52:17 -0500 Subject: Remove debug print --- src/invidious/yt_backend/extractors.cr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index fbb02824..1f7726fb 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -652,7 +652,6 @@ module HelperExtractors # # Returns a 0 when it's unable to do so def self.get_video_count(container : JSON::Any) : Int32 - puts container if box = container["videoCountText"]? if (extracted_text = extract_text(box)) && !extracted_text.includes? " subscriber" return extracted_text.gsub(/\D/, "").to_i -- cgit v1.2.3 From f6a4d04070203111c294520301ef6e439e110ade Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Wed, 18 Jan 2023 15:58:59 -0500 Subject: Redirect auth token to login --- src/invidious/routes/account.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index 9bb73136..e6a70ed2 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -203,7 +203,7 @@ module Invidious::Routes::Account referer = get_referer(env) if !user - return env.redirect referer + return env.redirect "/login?referer=#{URI.encode_path_segment(env.request.resource)}" end user = user.as(User) -- cgit v1.2.3 From cf93c94fc43bdb19160555747409b0a59b0c5f6b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 21 Jan 2023 15:23:15 +0100 Subject: Formatting fix for Crystal nightly Changes added by https://github.com/crystal-lang/crystal/pull/12951 --- src/invidious/helpers/json_filter.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/json_filter.cr b/src/invidious/helpers/json_filter.cr index b8e8f96d..3f4080ba 100644 --- a/src/invidious/helpers/json_filter.cr +++ b/src/invidious/helpers/json_filter.cr @@ -20,7 +20,7 @@ module JSONFilter /^\(|\(\(|\/\(/ end - def self.parse_fields(fields_text : String) : Nil + def self.parse_fields(fields_text : String, &) : Nil if fields_text.empty? raise FieldsParser::ParseError.new "Fields is empty" end @@ -42,7 +42,7 @@ module JSONFilter parse_nest_groups(fields_text) { |nest_list| yield nest_list } end - def self.parse_single_nests(fields_text : String) : Nil + def self.parse_single_nests(fields_text : String, &) : Nil single_nests = remove_nest_groups(fields_text) if !single_nests.empty? @@ -60,7 +60,7 @@ module JSONFilter end end - def self.parse_nest_groups(fields_text : String) : Nil + def self.parse_nest_groups(fields_text : String, &) : Nil nest_stack = [] of NamedTuple(group_name: String, closing_bracket_index: Int64) bracket_pairs = get_bracket_pairs(fields_text, true) -- cgit v1.2.3 From 7fd205179b5707a2774d83866f5a35b2bd8cfe16 Mon Sep 17 00:00:00 2001 From: Wes van der Vleuten <16665772+WesVleuten@users.noreply.github.com> Date: Sat, 21 Jan 2023 23:24:22 +0100 Subject: Added suggestions --- src/invidious/views/components/item.ecr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 47d077cf..fa12374f 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -1,4 +1,4 @@ -<% item_watched = !item.is_a?(SearchChannel) && !item.is_a?(SearchPlaylist) && !item.is_a?(InvidiousPlaylist) && !item.is_a?(Category) && env.get("user") && env.get("user").as(User).watched && env.get("user").as(User).watched.index(item.id) != nil %> +<% item_watched = !item.is_a?(SearchChannel | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil %>
    @@ -42,7 +42,7 @@ <% if item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> - + <% if item_watched %>
    @@ -74,7 +74,7 @@ <% elsif item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> - + <% if item_watched %>
    -- cgit v1.2.3 From caf9520c865133eb669025f9cd64607546e09a89 Mon Sep 17 00:00:00 2001 From: techmetx11 Date: Sun, 22 Jan 2023 00:12:04 +0100 Subject: Major improvements --- assets/css/default.css | 41 ++++++++++++++++++++++++++--------------- locales/en-US.json | 1 + src/invidious/videos.cr | 9 +++------ src/invidious/videos/parser.cr | 14 +++++++++----- src/invidious/views/watch.ecr | 39 ++++++++++++++++----------------------- 5 files changed, 55 insertions(+), 49 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 4ec6f720..9788e9f7 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -491,30 +491,27 @@ hr { /* Description Expansion Styling*/ #descexpansionbutton, -#musicdescexpansionbutton { - display: none +#music-desc-expansion { + display: none; } -#descexpansionbutton ~ div, -#musicdescexpansionbutton ~ div { +#descexpansionbutton ~ div { overflow: hidden; height: 8.3em; } -#descexpansionbutton:checked ~ div, -#musicdescexpansionbutton:checked ~ div { +#descexpansionbutton:checked ~ div { overflow: unset; height: 100%; } -#descexpansionbutton ~ label, -#musicdescexpansionbutton ~ label { +#descexpansionbutton ~ label { order: 1; margin-top: 20px; } label[for="descexpansionbutton"]:hover, -label[for="musicdescexpansionbutton"]:hover { +label[for="music-desc-expansion"]:hover { cursor: pointer; } @@ -527,8 +524,7 @@ h5, p, #descriptionWrapper, #description-box, -#music-description-box, -#musicDescriptionWrapper { +#music-description-box { unicode-bidi: plaintext; text-align: start; } @@ -538,12 +534,27 @@ p, white-space: pre-wrap; } -#musicDescriptionWrapper { - max-width: 600px; +#music-description-box { + display: none; +} + +#music-desc-expansion:checked ~ #music-description-box { + display: block; +} + +#music-desc-expansion ~ label > h3 > .ion-ios-arrow-up, +#music-desc-expansion:checked ~ label > h3 > .ion-ios-arrow-down { + display: none; +} + +#music-desc-expansion:checked ~ label > h3 > .ion-ios-arrow-up, +#music-desc-expansion ~ label > h3 > .ion-ios-arrow-down { + display: inline; } -#music-description-title { - margin-bottom: 0px; +/* Select all the music items except the first one */ +.music-item + .music-item { + border-top: 1px solid #ffffff; } /* Center the "invidious" logo on the search page */ diff --git a/locales/en-US.json b/locales/en-US.json index 20f1a46d..a5c16fd7 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -188,6 +188,7 @@ "Engagement: ": "Engagement: ", "Whitelisted regions: ": "Whitelisted regions: ", "Blacklisted regions: ": "Blacklisted regions: ", + "Music in this video": "Music in this video", "Artist: ": "Artist: ", "Album: ": "Album: ", "Shared `x`": "Shared `x`", diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index aa3ef1a8..436ac82d 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -248,12 +248,9 @@ struct Video end def music : Array(VideoMusic) - music_list = Array(VideoMusic).new - - info["music"].as_a.each do |music_json| - music_list << VideoMusic.new(music_json["album"].as_s, music_json["artist"].as_s, music_json["license"].as_s) - end - return music_list + info["music"].as_a.map { |music_json| + VideoMusic.new(music_json["album"].as_s, music_json["artist"].as_s, music_json["license"].as_s) + } end # Macros defining getters/setters for various types of data diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 69b04cb6..0abac32f 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -312,13 +312,18 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # Music section music_list = [] of VideoMusic - music_desclist = player_response.dig?("engagementPanels", 1, "engagementPanelSectionListRenderer", "content", "structuredDescriptionContentRenderer", "items", 2, "videoDescriptionMusicSectionRenderer", "carouselLockups").try &.as_a - music_desclist.try &.each do |music_desc| + music_desclist = player_response.dig?( + "engagementPanels", 1, "engagementPanelSectionListRenderer", + "content", "structuredDescriptionContentRenderer", "items", 2, + "videoDescriptionMusicSectionRenderer", "carouselLockups" + ) + + music_desclist.try &.as_a.each do |music_desc| artist = nil album = nil music_license = nil - music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.try &.each do |desc| + music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc| desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) if desc_title == "ARTIST" artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) @@ -328,8 +333,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) end end - music = VideoMusic.new(album.to_s, artist.to_s, music_license.to_s) - music_list << music + music_list << VideoMusic.new(album.to_s, artist.to_s, music_license.to_s) end # Author infos diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 207dae18..666eb3b0 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -34,13 +34,11 @@ we're going to need to do it here in order to allow for translations. --> @@ -238,27 +236,22 @@ we're going to need to do it here in order to allow for translations.
    <% if !video.music.empty? %> -

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

    + + +
    - <% if video.music.size == 1 %> -
    -

    <%= translate(locale, "Artist: ") %><%= video.music[0].artist %>

    -

    <%= translate(locale, "Album: ") %><%= video.music[0].album %>

    -

    <%= translate(locale, "License: ") %><%= video.music[0].license %>

    -
    - <% else %> - -
    - <% video.music.each do |music| %> -

    <%= translate(locale, "Artist: ") %><%= music.artist %>

    -

    <%= translate(locale, "Album: ") %><%= music.album %>

    -

    <%= translate(locale, "License: ") %><%= music.license %>

    -
    - <% end %> + <% video.music.each do |music| %> +
    +

    <%= translate(locale, "Artist: ") %><%= music.artist %>

    +

    <%= translate(locale, "Album: ") %><%= music.album %>

    +

    <%= translate(locale, "License: ") %><%= music.license %>

    - <% end %>

    -- cgit v1.2.3 From c2957dbce4a76b9a85fde9224b8c18edcb5821ba Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 24 Jan 2023 23:21:09 -0500 Subject: fix displaying author name #1612 --- src/invidious/channels/community.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 8e300288..76dff555 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -69,7 +69,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) next if !post content_html = post["contentText"]?.try { |t| parse_content(t) } || "" - author = post["authorText"]?.try &.["simpleText"]? || "" + author = post["authorText"]["runs"]?.try &.[0]?.try &.["text"]? || "" json.object do json.field "author", author -- cgit v1.2.3 From 13bf4e9e00030161165edf45b5e7d6e2ab1b3e30 Mon Sep 17 00:00:00 2001 From: Macic Date: Thu, 26 Jan 2023 01:19:12 +0100 Subject: Support handles --- src/invidious/routing.cr | 2 ++ 1 file changed, 2 insertions(+) (limited to 'src') diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 491022a5..157e6de7 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -132,6 +132,8 @@ module Invidious::Routing get "/c/:user#{path}", Routes::Channels, :brand_redirect # /user/linustechtips | Not always the same as /c/ get "/user/:user#{path}", Routes::Channels, :brand_redirect + # /@LinusTechTips | Handle + get "/@:user#{path}", Routes::Channels, :brand_redirect # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow get "/attribution_link#{path}", Routes::Channels, :brand_redirect # /profile?user=linustechtips -- cgit v1.2.3 From 96344f28b4b842e915325aef64bc93fc9fc55387 Mon Sep 17 00:00:00 2001 From: Gavin Johnson Date: Sat, 28 Jan 2023 09:26:16 -0800 Subject: added youtube playlist import functionality. fixes issue #2114 Signed-off-by: Gavin Johnson --- locales/en-US.json | 2 +- src/invidious/routes/preferences.cr | 10 +++ src/invidious/user/imports.cr | 123 +++++++++++++++--------------- src/invidious/views/feeds/playlists.ecr | 13 +++- src/invidious/views/user/data_control.ecr | 4 +- 5 files changed, 83 insertions(+), 69 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index c30a90db..8f1ec58d 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -33,7 +33,7 @@ "Import": "Import", "Import Invidious data": "Import Invidious JSON data", "Import YouTube subscriptions": "Import YouTube/OPML subscriptions", - "Import YouTube playlist": "Import YouTube playlist (.csv)", + "Import YouTube playlist (.csv)": "Import YouTube playlist (.csv)", "Import FreeTube subscriptions (.db)": "Import FreeTube subscriptions (.db)", "Import NewPipe subscriptions (.json)": "Import NewPipe subscriptions (.json)", "Import NewPipe data (.zip)": "Import NewPipe data (.zip)", diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index 570cba69..adac0068 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -310,6 +310,16 @@ module Invidious::Routes::PreferencesRoute response: error_template(415, "Invalid subscription file uploaded") ) end + # Gavin Johnson (thtmnisamnstr), 20230127: Call the Youtube playlist import function + when "import_youtube_pl" + filename = part.filename || "" + success = Invidious::User::Import.from_youtube_pl(user, body, filename, type) + + if !success + haltf(env, status_code: 415, + response: error_template(415, "Invalid playlist file uploaded") + ) + end when "import_freetube" Invidious::User::Import.from_freetube(user, body) when "import_newpipe_subscriptions" diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 870d083e..fa1bbe7f 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -30,73 +30,70 @@ struct Invidious::User return subscriptions end - # Parse a youtube CSV playlist file and create the playlist - #NEW - Done + # Gavin Johnson (thtmnisamnstr), 20230127: Parse a youtube CSV playlist file and create the playlist def parse_playlist_export_csv(user : User, csv_content : String) - rows = CSV.new(csv_content, headers: false) - if rows.size >= 2 - title = rows[1][4]?.try &.as_s?.try - descripton = rows[1][5]?.try &.as_s?.try - visibility = rows[1][6]?.try &.as_s?.try - - if visibility.compare("Public", case_insensitive: true) == 0 - privacy = PlaylistPrivacy:Public - else - privacy = PlaylistPrivacy:Private - end + rows = CSV.new(csv_content, headers: true) + row_counter = 0 + playlist = uninitialized InvidiousPlaylist + title = uninitialized String + description = uninitialized String + visibility = uninitialized String + rows.each do |row| + if row_counter == 0 + title = row[4] + description = row[5] + visibility = row[6] + + if visibility.compare("Public", case_insensitive: true) == 0 + privacy = PlaylistPrivacy::Public + else + privacy = PlaylistPrivacy::Private + end + + if title && privacy && user + playlist = create_playlist(title, privacy, user) + end + + if playlist && description + Invidious::Database::Playlists.update_description(playlist.id, description) + end - if title && privacy && user - playlist = create_playlist(title, privacy, user) + row_counter += 1 end - - if playlist && descripton - Invidious::Database::Playlists.update_description(playlist.id, description) + if row_counter > 0 && row_counter < 3 + row_counter += 1 end - end + if row_counter >= 3 + if playlist + video_id = row[0] + row_counter += 1 + next if !video_id - return playlist - end + begin + video = get_video(video_id) + rescue ex + next + end - # Parse a youtube CSV playlist file and add videos from it to a playlist - #NEW - done - def parse_playlist_videos_export_csv(playlist : Playlist, csv_content : String) - rows = CSV.new(csv_content, headers: false) - if rows.size >= 5 - offset = env.params.query["index"]?.try &.to_i? || 0 - row_counter = 0 - rows.each do |row| - if row_counter >= 4 - video_id = row[0]?.try &.as_s?.try - end - row_counter += 1 - next if !video_id + 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), + }) - begin - video = get_video(video_id) - rescue ex - next + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) 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), - }) - - Invidious::Database::PlaylistVideos.insert(playlist_video) - Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) end - - videos = get_playlist_videos(playlist, offset: offset) end - - return videos + + return playlist end # ------------------- @@ -218,20 +215,20 @@ struct Invidious::User return true end - # Import playlist from Youtube - # Returns success status - #NEW + # Gavin Johnson (thtmnisamnstr), 20230127: Import playlist from Youtube export. Returns success status. def from_youtube_pl(user : User, body : String, filename : String, type : String) : Bool extension = filename.split(".").last if extension == "csv" || type == "text/csv" playlist = parse_playlist_export_csv(user, body) - playlist = parse_playlist_videos_export_csv(playlist, body) + if playlist + return true + else + return false + end else return false end - - return true end # ------------------- diff --git a/src/invidious/views/feeds/playlists.ecr b/src/invidious/views/feeds/playlists.ecr index a59344c4..05a48ce3 100644 --- a/src/invidious/views/feeds/playlists.ecr +++ b/src/invidious/views/feeds/playlists.ecr @@ -5,14 +5,21 @@ <%= rendered "components/feed_menu" %>
    -
    +

    <%= translate(locale, "user_created_playlists", %(#{items_created.size})) %>

    -
    diff --git a/src/invidious/views/user/data_control.ecr b/src/invidious/views/user/data_control.ecr index 0f8e8dae..27654b40 100644 --- a/src/invidious/views/user/data_control.ecr +++ b/src/invidious/views/user/data_control.ecr @@ -8,7 +8,7 @@ <%= translate(locale, "Import") %>
    - +
    @@ -22,7 +22,7 @@
    - +
    -- cgit v1.2.3 From 5c7bda66ae90f3aef559a0269e56156a359814a3 Mon Sep 17 00:00:00 2001 From: Gavin Johnson Date: Sat, 28 Jan 2023 09:55:36 -0800 Subject: removed comments Signed-off-by: Gavin Johnson --- src/invidious/user/imports.cr | 2 -- 1 file changed, 2 deletions(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index fa1bbe7f..77009538 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -30,7 +30,6 @@ struct Invidious::User return subscriptions end - # Gavin Johnson (thtmnisamnstr), 20230127: Parse a youtube CSV playlist file and create the playlist def parse_playlist_export_csv(user : User, csv_content : String) rows = CSV.new(csv_content, headers: true) row_counter = 0 @@ -215,7 +214,6 @@ struct Invidious::User return true end - # Gavin Johnson (thtmnisamnstr), 20230127: Import playlist from Youtube export. Returns success status. def from_youtube_pl(user : User, body : String, filename : String, type : String) : Bool extension = filename.split(".").last -- cgit v1.2.3 From 72d0c9e40971b5460048f8906914fbef55289236 Mon Sep 17 00:00:00 2001 From: Gavin Johnson Date: Sat, 28 Jan 2023 09:57:28 -0800 Subject: removed comments Signed-off-by: Gavin Johnson --- src/invidious/routes/preferences.cr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/preferences.cr b/src/invidious/routes/preferences.cr index adac0068..abe0f34e 100644 --- a/src/invidious/routes/preferences.cr +++ b/src/invidious/routes/preferences.cr @@ -310,7 +310,6 @@ module Invidious::Routes::PreferencesRoute response: error_template(415, "Invalid subscription file uploaded") ) end - # Gavin Johnson (thtmnisamnstr), 20230127: Call the Youtube playlist import function when "import_youtube_pl" filename = part.filename || "" success = Invidious::User::Import.from_youtube_pl(user, body, filename, type) -- cgit v1.2.3 From 785fe5267480db83173e54423051bc528c545b0c Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 29 Jan 2023 10:28:42 -0500 Subject: API: Parse multiimage community posts --- src/invidious/channels/community.cr | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 76dff555..13af2d8b 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -189,6 +189,32 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) # when .has_key?("pollRenderer") # attachment = attachment["pollRenderer"] # json.field "type", "poll" + when .has_key?("postMultiImageRenderer") + attachment = attachment["postMultiImageRenderer"] + json.field "type", "multiImage" + json.field "images" do + json.array do + attachment["images"].as_a.each do |image| + json.array do + thumbnail = image["backstageImageRenderer"]["image"]["thumbnails"][0].as_h + width = thumbnail["width"].as_i + height = thumbnail["height"].as_i + aspect_ratio = (width.to_f / height.to_f) + url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") + + qualities = {320, 560, 640, 1280, 2000} + + qualities.each do |quality| + json.object do + json.field "url", url.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", (quality / aspect_ratio).ceil.to_i + end + end + end + end + end + end else json.field "type", "unknown" json.field "error", "Unrecognized attachment type." -- cgit v1.2.3 From e7a9aeff9538903d22363d2abcee28c62dc10895 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Mon, 30 Jan 2023 10:49:23 -0500 Subject: Add username to auth token callback --- src/invidious/routes/account.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index 9bb73136..d01aee56 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -262,6 +262,7 @@ module Invidious::Routes::Account end query["token"] = access_token + query["username"] = user.email url.query = query.to_s env.redirect url.to_s -- cgit v1.2.3 From bf5175d1e979005f6d04c9d7639c9db4aa08fb7b Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Thu, 2 Feb 2023 11:52:31 -0500 Subject: Feat: Add api endpoint to resolve youtube urls --- src/invidious/routes/api/v1/misc.cr | 27 +++++++++++++++++++++++++++ src/invidious/routing.cr | 1 + 2 files changed, 28 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 43d360e6..9679b530 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -150,4 +150,31 @@ module Invidious::Routes::API::V1::Misc response end + + # resolve channel and clip urls, return the UCID + def self.resolve_url(env) + env.response.content_type = "application/json" + url = env.params.query["url"]? + + return error_json(400, "Missing URL to resolve") if !url + + begin + resolved_url = YoutubeAPI.resolve_url(url.as(String)) + endpoint = resolved_url["endpoint"] + if resolved_ucid = endpoint.dig?("watchEndpoint", "videoId") + elsif resolved_ucid = endpoint.dig?("browseEndpoint", "browseId") + elsif pageType = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" + if pageType == "WEB_PAGE_TYPE_UNKNOWN" + return error_json(400, "Unknown url") + end + end + rescue ex + return error_json(500, ex) + end + JSON.build do |json| + json.object do + json.field "ucid", resolved_ucid.try &.as_s || "" + end + end + end end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 157e6de7..fb9851a3 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -281,6 +281,7 @@ module Invidious::Routing get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes + get "/api/v1/resolveurl", {{namespace}}::Misc, :resolve_url {% end %} end end -- cgit v1.2.3 From c162c7ff3f27498bd374b674bf7ca9b0c0790cc8 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Thu, 2 Feb 2023 18:20:14 -0500 Subject: add pageType --- src/invidious/routes/api/v1/misc.cr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 9679b530..e499f4d6 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -161,12 +161,11 @@ module Invidious::Routes::API::V1::Misc begin resolved_url = YoutubeAPI.resolve_url(url.as(String)) endpoint = resolved_url["endpoint"] + pageType = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" if resolved_ucid = endpoint.dig?("watchEndpoint", "videoId") elsif resolved_ucid = endpoint.dig?("browseEndpoint", "browseId") - elsif pageType = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" - if pageType == "WEB_PAGE_TYPE_UNKNOWN" - return error_json(400, "Unknown url") - end + elsif pageType == "WEB_PAGE_TYPE_UNKNOWN" + return error_json(400, "Unknown url") end rescue ex return error_json(500, ex) @@ -174,6 +173,7 @@ module Invidious::Routes::API::V1::Misc JSON.build do |json| json.object do json.field "ucid", resolved_ucid.try &.as_s || "" + json.field "pageType", pageType end end end -- cgit v1.2.3 From b2589c74bedb73a1ab6e0afe1a921b97f80c4b8e Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Thu, 2 Feb 2023 19:14:02 -0500 Subject: Add API for import/export --- src/invidious/routes/api/v1/authenticated.cr | 49 ++++++++++++++++++++++++++++ src/invidious/routing.cr | 3 ++ 2 files changed, 52 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 421355bb..c6042e40 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -31,6 +31,55 @@ module Invidious::Routes::API::V1::Authenticated env.response.status_code = 204 end + def self.export_invidious(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + playlists = Invidious::Database::Playlists.select_like_iv(user.email) + + return JSON.build do |json| + json.object do + json.field "subscriptions", user.subscriptions + json.field "watch_history", user.watched + json.field "preferences", user.preferences + json.field "playlists" do + json.array do + playlists.each do |playlist| + json.object do + json.field "title", playlist.title + json.field "description", html_to_content(playlist.description_html) + json.field "privacy", playlist.privacy.to_s + json.field "videos" do + json.array do + Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| + json.string video_id + end + end + end + end + end + end + end + end + end + end + + def self.import_invidious(env) + user = env.get("user").as(User) + + begin + if body = env.request.body + body = env.request.body.not_nil!.gets_to_end + else + body = "{}" + end + Invidious::User::Import.from_invidious(user, body) + rescue + end + + env.response.status_code = 204 + end + def self.feed(env) env.response.content_type = "application/json" diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 157e6de7..d5766b90 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -254,6 +254,9 @@ module Invidious::Routing get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences + get "/api/v1/auth/export/invidious", {{namespace}}::Authenticated, :export_invidious + post "/api/v1/auth/import/invidious", {{namespace}}::Authenticated, :import_invidious + get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions -- cgit v1.2.3 From 2606decd21a84ac3cba914f327f60a8403398ed9 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Sun, 5 Feb 2023 15:00:11 -0500 Subject: Refactor export function --- src/invidious/routes/api/v1/authenticated.cr | 28 +--------------------- src/invidious/routes/subscriptions.cr | 26 +-------------------- src/invidious/user/exports.cr | 35 ++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 52 deletions(-) create mode 100644 src/invidious/user/exports.cr (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index c6042e40..6b935312 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -35,33 +35,7 @@ module Invidious::Routes::API::V1::Authenticated env.response.content_type = "application/json" user = env.get("user").as(User) - playlists = Invidious::Database::Playlists.select_like_iv(user.email) - - return JSON.build do |json| - json.object do - json.field "subscriptions", user.subscriptions - json.field "watch_history", user.watched - json.field "preferences", user.preferences - json.field "playlists" do - json.array do - playlists.each do |playlist| - json.object do - json.field "title", playlist.title - json.field "description", html_to_content(playlist.description_html) - json.field "privacy", playlist.privacy.to_s - json.field "videos" do - json.array do - Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| - json.string video_id - end - end - end - end - end - end - end - end - end + return Invidious::User::Export.to_invidious(user) end def self.import_invidious(env) diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr index 7b1fa876..3090e026 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -106,31 +106,7 @@ module Invidious::Routes::Subscriptions env.response.headers["content-disposition"] = "attachment" playlists = Invidious::Database::Playlists.select_like_iv(user.email) - return JSON.build do |json| - json.object do - json.field "subscriptions", user.subscriptions - json.field "watch_history", user.watched - json.field "preferences", user.preferences - json.field "playlists" do - json.array do - playlists.each do |playlist| - json.object do - json.field "title", playlist.title - json.field "description", html_to_content(playlist.description_html) - json.field "privacy", playlist.privacy.to_s - json.field "videos" do - json.array do - Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| - json.string video_id - end - end - end - end - end - end - end - end - end + return Invidious::User::Export.to_invidious(user) else env.response.content_type = "application/xml" env.response.headers["content-disposition"] = "attachment" diff --git a/src/invidious/user/exports.cr b/src/invidious/user/exports.cr new file mode 100644 index 00000000..32be0ca2 --- /dev/null +++ b/src/invidious/user/exports.cr @@ -0,0 +1,35 @@ +struct Invidious::User + module Export + extend self + + def to_invidious(user : User) + playlists = Invidious::Database::Playlists.select_like_iv(user.email) + + return JSON.build do |json| + json.object do + json.field "subscriptions", user.subscriptions + json.field "watch_history", user.watched + json.field "preferences", user.preferences + json.field "playlists" do + json.array do + playlists.each do |playlist| + json.object do + json.field "title", playlist.title + json.field "description", html_to_content(playlist.description_html) + json.field "privacy", playlist.privacy.to_s + json.field "videos" do + json.array do + Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| + json.string video_id + end + end + end + end + end + end + end + end + end + end + end # module +end -- cgit v1.2.3 From 47a5b98e2554b32946864bc3320478d0dcc1daf8 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Sun, 5 Feb 2023 15:43:58 -0500 Subject: Remove unused db call --- src/invidious/routes/subscriptions.cr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr index 3090e026..0704c05e 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -104,7 +104,6 @@ module Invidious::Routes::Subscriptions if format == "json" env.response.content_type = "application/json" env.response.headers["content-disposition"] = "attachment" - playlists = Invidious::Database::Playlists.select_like_iv(user.email) return Invidious::User::Export.to_invidious(user) else -- cgit v1.2.3 From c37d8e36645d23afedff2729b8ad504cc5ba0655 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Sun, 5 Feb 2023 15:49:56 -0500 Subject: Use CONFIG.playlist_length_limit when exporting playlists --- src/invidious/user/exports.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/user/exports.cr b/src/invidious/user/exports.cr index 32be0ca2..b52503c9 100644 --- a/src/invidious/user/exports.cr +++ b/src/invidious/user/exports.cr @@ -19,7 +19,7 @@ struct Invidious::User json.field "privacy", playlist.privacy.to_s json.field "videos" do json.array do - Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: 500).each do |video_id| + Invidious::Database::PlaylistVideos.select_ids(playlist.id, playlist.index, limit: CONFIG.playlist_length_limit).each do |video_id| json.string video_id end end -- cgit v1.2.3 From 28424d0e881c8595bbc5797b9ef46e98103fe6d6 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 7 Feb 2023 09:23:26 -0500 Subject: Ignore casing for trending type in api --- src/invidious/trending.cr | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 1f957081..d164c37f 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -4,11 +4,14 @@ def fetch_trending(trending_type, region, locale) plid = nil - if trending_type == "Music" + trending_type ||= "default" + trending_type = trending_type.downcase + + if trending_type == "music" params = "4gINGgt5dG1hX2NoYXJ0cw%3D%3D" - elsif trending_type == "Gaming" + elsif trending_type == "gaming" params = "4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D" - elsif trending_type == "Movies" + elsif trending_type == "movies" params = "4gIKGgh0cmFpbGVycw%3D%3D" else # Default params = "" -- cgit v1.2.3 From 97825be10c4acf98962c9e65d63305cc77c21021 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 7 Feb 2023 21:52:53 -0500 Subject: add missing authorVerified to api --- src/invidious/helpers/serialized_yt_data.cr | 1 + src/invidious/routes/api/v1/channels.cr | 2 ++ 2 files changed, 3 insertions(+) (limited to 'src') diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 635f0984..c1874780 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -74,6 +74,7 @@ struct SearchVideo json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" + json.field "authorVerified", self.author_verified json.field "videoThumbnails" do Invidious::JSONify::APIv1.thumbnails(json, self.id) diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index ca2b2734..bcb4db2c 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -89,6 +89,8 @@ module Invidious::Routes::API::V1::Channels json.field "descriptionHtml", channel.description_html json.field "allowedRegions", channel.allowed_regions + json.field "tabs", channel.tabs + json.field "authorVerified", channel.verified json.field "latestVideos" do json.array do -- cgit v1.2.3 From b893bdac0d6b76a61e2cd972b29e44cf5b9c88f0 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 7 Feb 2023 22:02:35 -0500 Subject: parse isPinned, add support for strikethrough --- src/invidious/comments.cr | 3 +++ 1 file changed, 3 insertions(+) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index d691ca36..357a461c 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -181,6 +181,8 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b json.field "content", html_to_content(content_html) json.field "contentHtml", content_html + json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) + json.field "published", published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) @@ -670,6 +672,7 @@ def content_to_comment_html(content, video_id : String? = "") end text = "#{text}" if run["bold"]? + text = "#{text}" if run["strikethrough"]? text = "#{text}" if run["italics"]? text -- cgit v1.2.3 From d57d278f32e94d2bec75ffbc3c7bf28e6cb7638d Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Thu, 9 Feb 2023 15:00:23 -0500 Subject: Make itag optional under /latest_version --- src/invidious/routes/video_playback.cr | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 1e932d11..f24c0ded 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -256,7 +256,7 @@ module Invidious::Routes::VideoPlayback return error_template(400, "Invalid video ID") end - if itag.nil? || itag <= 0 || itag >= 1000 + if !itag.nil? && (itag <= 0 || itag >= 1000) return error_template(400, "Invalid itag") end @@ -277,7 +277,11 @@ module Invidious::Routes::VideoPlayback return error_template(500, ex) end - fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } + if itag.nil? + fmt = video.fmt_stream[-1] + else + fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } + end url = fmt.try &.["url"]?.try &.as_s if !url -- cgit v1.2.3 From e0c70d34cc3f937149d5c36c76aed8d8b57b4de5 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Thu, 9 Feb 2023 17:13:21 -0500 Subject: Make sure pinnedCommentBadge isn't equal to false Co-authored-by: Samantaz Fox --- src/invidious/comments.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 357a461c..41b0efa8 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -181,7 +181,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b json.field "content", html_to_content(content_html) json.field "contentHtml", content_html - json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) + json.field "isPinned", (node_comment["pinnedCommentBadge"]?.try(&.as_bool) == true) json.field "published", published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) -- cgit v1.2.3 From 6f01d6eacf0719e8569a338e5a44615f159c5120 Mon Sep 17 00:00:00 2001 From: thtmnisamnstr Date: Fri, 10 Feb 2023 12:00:02 -0800 Subject: ran crystal tool format. it should fix some CI issues Signed-off-by: thtmnisamnstr --- src/invidious/user/imports.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 77009538..aa87ca99 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -48,11 +48,11 @@ struct Invidious::User else privacy = PlaylistPrivacy::Private end - + if title && privacy && user - playlist = create_playlist(title, privacy, user) + playlist = create_playlist(title, privacy, user) end - + if playlist && description Invidious::Database::Playlists.update_description(playlist.id, description) end -- cgit v1.2.3 From 838cbeffcc7c8f85c83f6ab3e97362f803bd766c Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sat, 11 Feb 2023 08:41:26 -0500 Subject: Use case statement for trending_type Co-Authored-By: Samantaz Fox --- src/invidious/trending.cr | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index d164c37f..134eb437 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -4,14 +4,12 @@ def fetch_trending(trending_type, region, locale) plid = nil - trending_type ||= "default" - trending_type = trending_type.downcase - - if trending_type == "music" + case trending_type.try &.downcase + when "music" params = "4gINGgt5dG1hX2NoYXJ0cw%3D%3D" - elsif trending_type == "gaming" + when "gaming" params = "4gIcGhpnYW1pbmdfY29ycHVzX21vc3RfcG9wdWxhcg%3D%3D" - elsif trending_type == "movies" + when "movies" params = "4gIKGgh0cmFpbGVycw%3D%3D" else # Default params = "" -- cgit v1.2.3 From 87342e4efd4258f52d2b34f0e5af0fcf7ca09f90 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 12 Feb 2023 17:57:07 +0100 Subject: Comments: Revert "isPinned" to a nil check "pinnedCommentBadge" is not a boolean, but a complex structure. This commit fixes a wrong assumption I had during the rewiew of https://github.com/iv-org/invidious/pull/3626 --- src/invidious/comments.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 41b0efa8..357a461c 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -181,7 +181,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b json.field "content", html_to_content(content_html) json.field "contentHtml", content_html - json.field "isPinned", (node_comment["pinnedCommentBadge"]?.try(&.as_bool) == true) + json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) json.field "published", published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) -- cgit v1.2.3 From 8384fa94c2de8c4bf561f4fe5964ce802f22a545 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 14 Feb 2023 22:48:37 -0500 Subject: Community: Parse polls --- src/invidious/channels/community.cr | 38 +++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 13af2d8b..a0e79c22 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -185,10 +185,40 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - # TODO - # when .has_key?("pollRenderer") - # attachment = attachment["pollRenderer"] - # json.field "type", "poll" + when .has_key?("pollRenderer") + attachment = attachment["pollRenderer"] + json.field "type", "poll" + json.field "totalVotes", attachment["totalVotes"]["simpleText"].as_s + json.field "choices" do + json.array do + attachment["choices"].as_a.each do |choice| + json.object do + json.field "text", choice["text"]["runs"][0]["text"].as_s + # A choice can have an image associated with it. + # Ex post: https://www.youtube.com/post/UgkxD4XavXUD4NQiddJXXdohbwOwcVqrH9Re + if choice["image"]? + thumbnail = choice["image"]["thumbnails"][0].as_h + width = thumbnail["width"].as_i + height = thumbnail["height"].as_i + aspect_ratio = (width.to_f / height.to_f) + url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") + qualities = {320, 560, 640, 1280, 2000} + json.field "image" do + json.array do + qualities.each do |quality| + json.object do + json.field "url", url.gsub(/=s\d+/, "=s#{quality}") + json.field "width", quality + json.field "height", (quality / aspect_ratio).ceil.to_i + end + end + end + end + end + end + end + end + end when .has_key?("postMultiImageRenderer") attachment = attachment["postMultiImageRenderer"] json.field "type", "multiImage" -- cgit v1.2.3 From aecbafbc7beb5c007031c02cfba9f419c58d4545 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 14 Feb 2023 22:52:59 -0500 Subject: Community: parse replyCount --- src/invidious/channels/community.cr | 3 +++ 1 file changed, 3 insertions(+) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index a0e79c22..9f9f3fde 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -108,6 +108,8 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) like_count = post["actionButtons"]["commentActionButtonsRenderer"]["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"] .try &.as_s.gsub(/\D/, "").to_i? || 0 + reply_count = short_text_to_number(post.dig?("actionButtons", "commentActionButtonsRenderer", "replyButton", "buttonRenderer", "text", "simpleText").try &.as_s || "0") + json.field "content", html_to_content(content_html) json.field "contentHtml", content_html @@ -115,6 +117,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) json.field "likeCount", like_count + json.field "replyCount", reply_count json.field "commentId", post["postId"]? || post["commentId"]? || "" json.field "authorIsChannelOwner", post["authorEndpoint"]["browseEndpoint"]["browseId"] == ucid -- cgit v1.2.3 From 4731480821247a542ff05a8faedefcef55c009d9 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 14 Feb 2023 23:03:25 -0500 Subject: parse votes as number Co-Authored-By: syeopite <70992037+syeopite@users.noreply.github.com> --- src/invidious/channels/community.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 9f9f3fde..87659c47 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -191,7 +191,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) when .has_key?("pollRenderer") attachment = attachment["pollRenderer"] json.field "type", "poll" - json.field "totalVotes", attachment["totalVotes"]["simpleText"].as_s + json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0]) json.field "choices" do json.array do attachment["choices"].as_a.each do |choice| -- cgit v1.2.3 From d03a62641f20a8dfd15fc9fe50373a5e75ee3d6e Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Wed, 15 Feb 2023 00:20:45 -0500 Subject: Add support for custom emojis in comments --- src/invidious/comments.cr | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 357a461c..5749248e 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -182,7 +182,11 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b json.field "contentHtml", content_html json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) - + json.field "isMember", (node_comment["sponsorCommentBadge"]? != nil) + if node_comment["sponsorCommentBadge"]? + # Member icon thumbnails always have one object and there's only ever the url property in it + json.field "memberIconUrl", node_comment["sponsorCommentBadge"]["sponsorCommentBadgeRenderer"]["customBadge"]["thumbnails"][0]["url"].to_s + end json.field "published", published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) @@ -674,6 +678,14 @@ def content_to_comment_html(content, video_id : String? = "") text = "#{text}" if run["bold"]? text = "#{text}" if run["strikethrough"]? text = "#{text}" if run["italics"]? + if emojiImage = run.dig?("emoji", "image") + emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text + emojiThumb = emojiImage["thumbnails"][0] + emojiUrl = "/ggpht#{URI.parse(emojiThumb["url"].as_s).request_target}" + emojiWidth = emojiThumb["width"] + emojiHeight = emojiThumb["height"] + text = "\"#{emojiAlt}\"" + end text end -- cgit v1.2.3 From 76ad4e802603f82fe45d522a9c268e972d428a75 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Thu, 16 Feb 2023 14:12:56 -0500 Subject: show member icon, hide deleted emojis, fix non-custom emojis --- locales/en-US.json | 1 + src/invidious/comments.cr | 29 ++++++++++++++++++++++------- 2 files changed, 23 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index a5c16fd7..5bbf6db6 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -405,6 +405,7 @@ "YouTube comment permalink": "YouTube comment permalink", "permalink": "permalink", "`x` marked it with a ❤": "`x` marked it with a ❤", + "Member": "Member", "Audio mode": "Audio mode", "Video mode": "Video mode", "Playlists": "Playlists", diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 5749248e..f1942ceb 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -328,11 +328,21 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end author_name = HTML.escape(child["author"].as_s) + member_icon = "" if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool author_name += " " elsif child["verified"]?.try &.as_bool author_name += " " end + if child["isMember"]?.try &.as_bool + member_icon = "\"\"" + end html << <<-END_HTML
    @@ -343,6 +353,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) #{author_name} + #{member_icon}

    #{child["contentHtml"]}

    END_HTML @@ -678,13 +689,17 @@ def content_to_comment_html(content, video_id : String? = "") text = "#{text}" if run["bold"]? text = "#{text}" if run["strikethrough"]? text = "#{text}" if run["italics"]? - if emojiImage = run.dig?("emoji", "image") - emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text - emojiThumb = emojiImage["thumbnails"][0] - emojiUrl = "/ggpht#{URI.parse(emojiThumb["url"].as_s).request_target}" - emojiWidth = emojiThumb["width"] - emojiHeight = emojiThumb["height"] - text = "\"#{emojiAlt}\"" + if run["emoji"]? + if run["emoji"]["isCustomEmoji"]?.try &.as_bool + if emojiImage = run.dig?("emoji", "image") + emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text + emojiThumb = emojiImage["thumbnails"][0] + text = "\"#{emojiAlt}\"" + else + # Hide deleted channel emoji + text = "" + end + end end text -- cgit v1.2.3 From bc5d81fe60b324459ac428f4269316bd4cfdc3a1 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 19 Feb 2023 12:46:46 -0500 Subject: use string builder to create images change member to sponsor --- locales/en-US.json | 2 +- src/invidious/comments.cr | 36 ++++++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 5bbf6db6..bd2b9d44 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -405,7 +405,7 @@ "YouTube comment permalink": "YouTube comment permalink", "permalink": "permalink", "`x` marked it with a ❤": "`x` marked it with a ❤", - "Member": "Member", + "Channel Sponsor": "Channel Sponsor", "Audio mode": "Audio mode", "Video mode": "Video mode", "Playlists": "Playlists", diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index f1942ceb..b866b6ef 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -182,10 +182,10 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b json.field "contentHtml", content_html json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) - json.field "isMember", (node_comment["sponsorCommentBadge"]? != nil) + json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil) if node_comment["sponsorCommentBadge"]? - # Member icon thumbnails always have one object and there's only ever the url property in it - json.field "memberIconUrl", node_comment["sponsorCommentBadge"]["sponsorCommentBadgeRenderer"]["customBadge"]["thumbnails"][0]["url"].to_s + # Sponsor icon thumbnails always have one object and there's only ever the url property in it + json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s end json.field "published", published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) @@ -328,20 +328,19 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end author_name = HTML.escape(child["author"].as_s) - member_icon = "" + sponsor_icon = "" if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool author_name += " " elsif child["verified"]?.try &.as_bool author_name += " " end - if child["isMember"]?.try &.as_bool - member_icon = "\"\"" + if child["isSponsor"].as_bool + sponsor_icon = String.build do |str| + str << %() + end end html << <<-END_HTML
    @@ -353,7 +352,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) #{author_name} - #{member_icon} + #{sponsor_icon}

    #{child["contentHtml"]}

    END_HTML @@ -689,12 +688,21 @@ def content_to_comment_html(content, video_id : String? = "") text = "#{text}" if run["bold"]? text = "#{text}" if run["strikethrough"]? text = "#{text}" if run["italics"]? + + # check for custom emojis if run["emoji"]? if run["emoji"]["isCustomEmoji"]?.try &.as_bool if emojiImage = run.dig?("emoji", "image") emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text emojiThumb = emojiImage["thumbnails"][0] - text = "\"#{emojiAlt}\"" + text = String.build do |str| + str << %() << emojiAlt << ') + end else # Hide deleted channel emoji text = "" -- cgit v1.2.3 From bde21d527f1fae4a84b964f1b297d7b246526ba0 Mon Sep 17 00:00:00 2001 From: Wes van der Vleuten <16665772+WesVleuten@users.noreply.github.com> Date: Sun, 19 Feb 2023 20:41:18 +0100 Subject: Fixed console error --- assets/js/watched_indicator.js | 24 ++++++++++++++++++++++++ assets/js/watched_widget.js | 24 ------------------------ src/invidious/views/add_playlist_items.ecr | 2 +- src/invidious/views/channel.ecr | 2 +- src/invidious/views/edit_playlist.ecr | 2 +- src/invidious/views/feeds/playlists.ecr | 2 +- src/invidious/views/feeds/popular.ecr | 2 +- src/invidious/views/feeds/subscriptions.ecr | 3 ++- src/invidious/views/feeds/trending.ecr | 2 +- src/invidious/views/hashtag.ecr | 2 +- src/invidious/views/playlist.ecr | 2 +- src/invidious/views/search.ecr | 2 +- 12 files changed, 35 insertions(+), 34 deletions(-) create mode 100644 assets/js/watched_indicator.js (limited to 'src') diff --git a/assets/js/watched_indicator.js b/assets/js/watched_indicator.js new file mode 100644 index 00000000..e971cd80 --- /dev/null +++ b/assets/js/watched_indicator.js @@ -0,0 +1,24 @@ +'use strict'; +var save_player_pos_key = 'save_player_pos'; + +function get_all_video_times() { + return helpers.storage.get(save_player_pos_key) || {}; +} + +document.querySelectorAll('.watched-indicator').forEach(function (indicator) { + var watched_part = get_all_video_times()[indicator.dataset.id]; + var total = parseInt(indicator.dataset.length, 10); + if (watched_part === undefined) { + watched_part = total; + } + var percentage = Math.round((watched_part / total) * 100); + + if (percentage < 5) { + percentage = 5; + } + if (percentage > 90) { + percentage = 100; + } + + indicator.style.width = percentage + '%'; +}); diff --git a/assets/js/watched_widget.js b/assets/js/watched_widget.js index 02537111..f1ac9cb4 100644 --- a/assets/js/watched_widget.js +++ b/assets/js/watched_widget.js @@ -32,27 +32,3 @@ function mark_unwatched(target) { } }); } - -var save_player_pos_key = 'save_player_pos'; - -function get_all_video_times() { - return helpers.storage.get(save_player_pos_key) || {}; -} - -document.querySelectorAll('.watched-indicator').forEach(function (indicator) { - var watched_part = get_all_video_times()[indicator.dataset.id]; - var total = parseInt(indicator.dataset.length, 10); - if (watched_part === undefined) { - watched_part = total; - } - var percentage = Math.round((watched_part / total) * 100); - - if (percentage < 5) { - percentage = 5; - } - if (percentage > 90) { - percentage = 100; - } - - indicator.style.width = percentage + '%'; -}); diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index 70575de3..bcba74cf 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -39,7 +39,7 @@ <% end %>
    - + <% if query %> <%- query_encoded = URI.encode_www_form(query.text, space_to_plus: true) -%> diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 931dd407..6e62a471 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -49,7 +49,7 @@ <% end %>
    - +
    diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr index 100764c7..548104c8 100644 --- a/src/invidious/views/edit_playlist.ecr +++ b/src/invidious/views/edit_playlist.ecr @@ -62,7 +62,7 @@ <% end %>
    - +
    diff --git a/src/invidious/views/feeds/playlists.ecr b/src/invidious/views/feeds/playlists.ecr index f9064762..e52a7707 100644 --- a/src/invidious/views/feeds/playlists.ecr +++ b/src/invidious/views/feeds/playlists.ecr @@ -33,4 +33,4 @@ <% end %>
    - + diff --git a/src/invidious/views/feeds/popular.ecr b/src/invidious/views/feeds/popular.ecr index 919002cd..5fbe539c 100644 --- a/src/invidious/views/feeds/popular.ecr +++ b/src/invidious/views/feeds/popular.ecr @@ -17,4 +17,4 @@ <% end %>
    - + diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr index d4e93240..9c69c5b0 100644 --- a/src/invidious/views/feeds/subscriptions.ecr +++ b/src/invidious/views/feeds/subscriptions.ecr @@ -54,6 +54,7 @@ }.to_pretty_json %> +
    <% videos.each do |item| %> @@ -61,7 +62,7 @@ <% end %>
    - +
    diff --git a/src/invidious/views/feeds/trending.ecr b/src/invidious/views/feeds/trending.ecr index 76218165..7dc416c6 100644 --- a/src/invidious/views/feeds/trending.ecr +++ b/src/invidious/views/feeds/trending.ecr @@ -46,4 +46,4 @@ <% end %>
    - + diff --git a/src/invidious/views/hashtag.ecr b/src/invidious/views/hashtag.ecr index 6064af74..3351c21c 100644 --- a/src/invidious/views/hashtag.ecr +++ b/src/invidious/views/hashtag.ecr @@ -24,7 +24,7 @@ <%- end -%>
    - +
    diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 1df047ba..a04acf4c 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -106,7 +106,7 @@ <% end %>
    - +
    diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index c4960d08..a7469e36 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -37,7 +37,7 @@
    <%- end -%> - +
    -- cgit v1.2.3 From b5eb6016bbc455921ce3d8ec24589d706f8a5fb1 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 19 Feb 2023 14:51:39 -0500 Subject: add spaces at end of attribute --- src/invidious/comments.cr | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index b866b6ef..6c323bc1 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -336,10 +336,10 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end if child["isSponsor"].as_bool sponsor_icon = String.build do |str| - str << %() + str << %() end end html << <<-END_HTML @@ -696,12 +696,12 @@ def content_to_comment_html(content, video_id : String? = "") emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text emojiThumb = emojiImage["thumbnails"][0] text = String.build do |str| - str << %() << emojiAlt << ') + str << %() << emojiAlt << ) end else # Hide deleted channel emoji -- cgit v1.2.3 From 8445d3ae120c52eba531183caa1fa63d5701f322 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Sun, 19 Feb 2023 19:01:28 -0500 Subject: Fix watch history order --- src/invidious/database/users.cr | 1 + src/invidious/routes/watch.cr | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr index 0a4a4fd8..f8422874 100644 --- a/src/invidious/database/users.cr +++ b/src/invidious/database/users.cr @@ -50,6 +50,7 @@ module Invidious::Database::Users end def mark_watched(user : User, vid : String) + mark_unwatched(user, vid) request = <<-SQL UPDATE users SET watched = array_append(watched, $1) diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 5d3845c3..813cb0f4 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -76,7 +76,7 @@ module Invidious::Routes::Watch end env.params.query.delete_all("iv_load_policy") - if watched && preferences.watch_history && !watched.includes? id + if watched && preferences.watch_history Invidious::Database::Users.mark_watched(user.as(User), id) end @@ -259,9 +259,7 @@ module Invidious::Routes::Watch case action when "action_mark_watched" - if !user.watched.includes? id - Invidious::Database::Users.mark_watched(user, id) - end + Invidious::Database::Users.mark_watched(user, id) when "action_mark_unwatched" Invidious::Database::Users.mark_unwatched(user, id) else -- cgit v1.2.3 From 20289a4d014d36c9a7bd50d8b1549bf36f78eb59 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Mon, 20 Feb 2023 14:56:38 -0500 Subject: Fix order for import --- src/invidious/user/imports.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 20ae0d47..aa947456 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -48,7 +48,7 @@ struct Invidious::User if data["watch_history"]? user.watched += data["watch_history"].as_a.map(&.as_s) - user.watched.uniq! + user.watched.reverse!.uniq!.reverse! Invidious::Database::Users.update_watch_history(user) end -- cgit v1.2.3 From 7b124eec640ca601d2cafc366867e1d6cd283577 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Mon, 20 Feb 2023 16:27:16 -0500 Subject: Add History API --- src/invidious/routes/api/v1/authenticated.cr | 50 ++++++++++++++++++++++++++++ src/invidious/routing.cr | 5 +++ 2 files changed, 55 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 6b935312..e670a87c 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -54,6 +54,56 @@ module Invidious::Routes::API::V1::Authenticated env.response.status_code = 204 end + def self.get_history(env) + env.response.content_type = "application/json" + user = env.get("user").as(User) + + page = env.params.query["page"]?.try &.to_i? + page ||= 1 + + max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) + max_results ||= user.preferences.max_results + max_results ||= CONFIG.default_user_preferences.max_results + + if user.watched[(page - 1) * max_results]? + watched = user.watched.reverse[(page - 1) * max_results, max_results] + end + watched ||= [] of String + + return watched.to_json + end + + def self.mark_watched(env) + user = env.get("user").as(User) + + id = env.params.url["id"]?.try &.as(String) + if !id + return error_json(400, "Invalid video id.") + end + + Invidious::Database::Users.mark_watched(user, id) + env.response.status_code = 204 + end + + def self.mark_unwatched(env) + user = env.get("user").as(User) + + id = env.params.url["id"]?.try &.as(String) + if !id + return error_json(400, "Invalid video id.") + end + + Invidious::Database::Users.mark_unwatched(user, id) + env.response.status_code = 204 + end + + def self.clear_history(env) + user = env.get("user").as(User) + + Invidious::Database::Users.clear_watch_history(user) + env.response.status_code = 204 + end + def self.feed(env) env.response.content_type = "application/json" diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index dca2f117..9e2ade3d 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -257,6 +257,11 @@ module Invidious::Routing get "/api/v1/auth/export/invidious", {{namespace}}::Authenticated, :export_invidious post "/api/v1/auth/import/invidious", {{namespace}}::Authenticated, :import_invidious + get "/api/v1/auth/history", {{namespace}}::Authenticated, :get_history + post "/api/v1/auth/history/:id", {{namespace}}::Authenticated, :mark_watched + delete "/api/v1/auth/history/:id", {{namespace}}::Authenticated, :mark_unwatched + delete "/api/v1/auth/history", {{namespace}}::Authenticated, :clear_history + get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions -- cgit v1.2.3 From 15e9510ab212ac1f8b6bc2a5a3e83ebc4ba1fe90 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Mon, 20 Feb 2023 16:43:36 -0500 Subject: Check preferences before marking video as watched --- src/invidious/routes/api/v1/authenticated.cr | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index e670a87c..dc86bb3c 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -76,6 +76,10 @@ module Invidious::Routes::API::V1::Authenticated def self.mark_watched(env) user = env.get("user").as(User) + if !user.preferences.watch_history + return error_json(409, "Watch history is disabled in preferences.") + end + id = env.params.url["id"]?.try &.as(String) if !id return error_json(400, "Invalid video id.") -- cgit v1.2.3 From 6ee51f460a27618d5926e9caf230a7ada2823e70 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Tue, 21 Feb 2023 15:24:25 -0500 Subject: encode username on callback --- src/invidious/routes/account.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index d01aee56..284d5b06 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -262,7 +262,7 @@ module Invidious::Routes::Account end query["token"] = access_token - query["username"] = user.email + query["username"] = URI.encode_path_segment(user.email) url.query = query.to_s env.redirect url.to_s -- cgit v1.2.3 From b3eea6ab3ebdb1618916b02041b22e0e238e8a7d Mon Sep 17 00:00:00 2001 From: thtmnisamnstr Date: Thu, 23 Feb 2023 15:55:38 -0800 Subject: improved import algorithm, fixed a referer issue from the playlists page after deleting a playlist Signed-off-by: thtmnisamnstr --- src/invidious/user/imports.cr | 57 +++++++++++++++++---------------- src/invidious/views/feeds/playlists.ecr | 4 +-- 2 files changed, 31 insertions(+), 30 deletions(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index aa87ca99..7fddcc4c 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -30,43 +30,44 @@ struct Invidious::User return subscriptions end - def parse_playlist_export_csv(user : User, csv_content : String) - rows = CSV.new(csv_content, headers: true) - row_counter = 0 + def parse_playlist_export_csv(user : User, raw_input : String) playlist = uninitialized InvidiousPlaylist title = uninitialized String description = uninitialized String visibility = uninitialized String - rows.each do |row| - if row_counter == 0 - title = row[4] - description = row[5] - visibility = row[6] - - if visibility.compare("Public", case_insensitive: true) == 0 - privacy = PlaylistPrivacy::Public - else - privacy = PlaylistPrivacy::Private - end + privacy = uninitialized PlaylistPrivacy + + # Split the input into head and body content + raw_head, raw_body = raw_input.split("\n\n", limit: 2, remove_empty: true) + + # Create the playlist from the head content + csv_head = CSV.new(raw_head, headers: true) + csv_head.next + title = csv_head[4] + description = csv_head[5] + visibility = csv_head[6] + + if visibility.compare("Public", case_insensitive: true) == 0 + privacy = PlaylistPrivacy::Public + else + privacy = PlaylistPrivacy::Private + end - if title && privacy && user - playlist = create_playlist(title, privacy, user) - end + if title && privacy && user + playlist = create_playlist(title, privacy, user) + end - if playlist && description - Invidious::Database::Playlists.update_description(playlist.id, description) - end + if playlist && description + Invidious::Database::Playlists.update_description(playlist.id, description) + end - row_counter += 1 - end - if row_counter > 0 && row_counter < 3 - row_counter += 1 - end - if row_counter >= 3 + # Add each video to the playlist from the body content + CSV.each_row(raw_body) do |row| + if row.size >= 1 + video_id = row[0] if playlist - video_id = row[0] - row_counter += 1 next if !video_id + next if video_id == "Video Id" begin video = get_video(video_id) diff --git a/src/invidious/views/feeds/playlists.ecr b/src/invidious/views/feeds/playlists.ecr index 05a48ce3..43173355 100644 --- a/src/invidious/views/feeds/playlists.ecr +++ b/src/invidious/views/feeds/playlists.ecr @@ -10,12 +10,12 @@

    - + "> <%= translate(locale, "Import/export") %>

    -- cgit v1.2.3 From 8eca5b270ed10b6233371f5495cf059bc353dcb1 Mon Sep 17 00:00:00 2001 From: techmetx11 Date: Sat, 14 Jan 2023 01:49:58 +0100 Subject: Video: Fix 0 views, and empty license field --- locales/en-US.json | 1 + src/invidious/videos/parser.cr | 2 +- src/invidious/views/watch.ecr | 6 +++++- 3 files changed, 7 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index a5c16fd7..fbcc1341 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -183,6 +183,7 @@ "Show annotations": "Show annotations", "Genre: ": "Genre: ", "License: ": "License: ", + "Standard YouTube license": "Standard YouTube license", "Family friendly? ": "Family friendly? ", "Wilson score: ": "Wilson score: ", "Engagement: ": "Engagement: ", diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index cf43f1be..04ee7303 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -186,7 +186,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # then from videoDetails, as the latter is "0" for livestreams (we want # to get the amount of viewers watching). views_txt = video_primary_renderer - .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "runs", 0, "text") + .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "simpleText") views_txt ||= video_details["viewCount"]? views = views_txt.try &.as_s.gsub(/\D/, "").to_i64? diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 666eb3b0..c23a9552 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -181,7 +181,11 @@ we're going to need to do it here in order to allow for translations. <% end %>

    <% if video.license %> -

    <%= translate(locale, "License: ") %><%= video.license %>

    + <% if video.license == "" %> +

    <%= translate(locale, "License: ") %><%= translate(locale, "Standard YouTube license") %>

    + <% else %> +

    <%= translate(locale, "License: ") %><%= video.license %>

    + <% end %> <% end %>

    <%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %>

    -- cgit v1.2.3 From 4ac263f1dfdc18e5de584d4fb8bbfd74141e2716 Mon Sep 17 00:00:00 2001 From: techmetx11 Date: Sun, 15 Jan 2023 16:26:51 +0100 Subject: Replace == with empty? --- src/invidious/views/watch.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index c23a9552..1fc79495 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -181,7 +181,7 @@ we're going to need to do it here in order to allow for translations. <% end %>

    <% if video.license %> - <% if video.license == "" %> + <% if video.license.empty? %>

    <%= translate(locale, "License: ") %><%= translate(locale, "Standard YouTube license") %>

    <% else %>

    <%= translate(locale, "License: ") %><%= video.license %>

    -- cgit v1.2.3 From 27bf4d02a185e6750cdecdc4f1c169b0723dbbf5 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Wed, 1 Mar 2023 22:08:19 -0500 Subject: PR nursing --- src/invidious/routes/api/v1/authenticated.cr | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index dc86bb3c..a20d23d0 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -58,15 +58,16 @@ module Invidious::Routes::API::V1::Authenticated env.response.content_type = "application/json" user = env.get("user").as(User) - page = env.params.query["page"]?.try &.to_i? + page = env.params.query["page"]?.try &.to_i?.try &.clamp(0, Int32::MAX) page ||= 1 max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) max_results ||= user.preferences.max_results max_results ||= CONFIG.default_user_preferences.max_results - if user.watched[(page - 1) * max_results]? - watched = user.watched.reverse[(page - 1) * max_results, max_results] + start_index = (page - 1) * max_results + if user.watched[start_index]? + watched = user.watched.reverse[start_index, max_results] end watched ||= [] of String -- cgit v1.2.3 From 4a1471346237f44481b4de823e87d393739e12c1 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Wed, 1 Mar 2023 23:39:07 -0500 Subject: use dig, create private image quality constant Co-Authored-By: Samantaz Fox --- src/invidious/channels/community.cr | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 87659c47..da8be6ea 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -1,3 +1,5 @@ +private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000} + # TODO: Add "sort_by" def fetch_channel_community(ucid, continuation, locale, format, thin_mode) response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") @@ -75,10 +77,9 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) json.field "author", author json.field "authorThumbnails" do json.array do - qualities = {32, 48, 76, 100, 176, 512} author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s - qualities.each do |quality| + IMAGE_QUALITIES.each do |quality| json.object do json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-") json.field "width", quality @@ -177,9 +178,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) aspect_ratio = (width.to_f / height.to_f) url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - qualities = {320, 560, 640, 1280, 2000} - - qualities.each do |quality| + IMAGE_QUALITIES.each do |quality| json.object do json.field "url", url.gsub(/=s\d+/, "=s#{quality}") json.field "width", quality @@ -196,7 +195,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) json.array do attachment["choices"].as_a.each do |choice| json.object do - json.field "text", choice["text"]["runs"][0]["text"].as_s + json.field "text", choice.dig("text", "runs", 0, "text").as_s # A choice can have an image associated with it. # Ex post: https://www.youtube.com/post/UgkxD4XavXUD4NQiddJXXdohbwOwcVqrH9Re if choice["image"]? @@ -205,10 +204,9 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) height = thumbnail["height"].as_i aspect_ratio = (width.to_f / height.to_f) url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - qualities = {320, 560, 640, 1280, 2000} json.field "image" do json.array do - qualities.each do |quality| + IMAGE_QUALITIES.each do |quality| json.object do json.field "url", url.gsub(/=s\d+/, "=s#{quality}") json.field "width", quality @@ -235,9 +233,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) aspect_ratio = (width.to_f / height.to_f) url = thumbnail["url"].as_s.gsub(/=w\d+-h\d+(-p)?(-nd)?(-df)?(-rwa)?/, "=s640") - qualities = {320, 560, 640, 1280, 2000} - - qualities.each do |quality| + IMAGE_QUALITIES.each do |quality| json.object do json.field "url", url.gsub(/=s\d+/, "=s#{quality}") json.field "width", quality -- cgit v1.2.3 From 60b7c8015c9ae77664d0b0680a81cfcc979d5a03 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Thu, 2 Mar 2023 07:29:44 -0500 Subject: add channel emoji css class --- assets/css/default.css | 4 ++++ src/invidious/comments.cr | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 9788e9f7..5ec79a43 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -565,3 +565,7 @@ p, /* Wider settings name to less word wrap */ .pure-form-aligned .pure-control-group label { width: 19em; } + +.channel-emoji { + margin: 0 2px; +} diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 6c323bc1..56622dec 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -701,7 +701,7 @@ def content_to_comment_html(content, video_id : String? = "") str << %(title=") << emojiAlt << "\" " str << %(width=") << emojiThumb["width"] << "\" " str << %(height=") << emojiThumb["height"] << "\" " - str << %(style="margin-right:2px;margin-left:2px;"/>) + str << %(class="channel-emoji"/>) end else # Hide deleted channel emoji -- cgit v1.2.3 From 8c0efb3ea9e409796ae860128b16d8aac860c5c6 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Thu, 2 Mar 2023 14:45:26 -0500 Subject: validate video id --- src/invidious/routes/api/v1/authenticated.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index a20d23d0..75dad6df 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -94,7 +94,7 @@ module Invidious::Routes::API::V1::Authenticated user = env.get("user").as(User) id = env.params.url["id"]?.try &.as(String) - if !id + if !id.match(/[a-zA-Z0-9_-]{11}/) return error_json(400, "Invalid video id.") end -- cgit v1.2.3 From 38f6d08be6559915262cd246b7a82988700250a5 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Thu, 2 Mar 2023 14:47:14 -0500 Subject: Validate id, avoid db call if not needed --- src/invidious/routes/api/v1/authenticated.cr | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index 75dad6df..e8e7c524 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -82,7 +82,7 @@ module Invidious::Routes::API::V1::Authenticated end id = env.params.url["id"]?.try &.as(String) - if !id + if !id.match(/[a-zA-Z0-9_-]{11}/) return error_json(400, "Invalid video id.") end @@ -93,6 +93,10 @@ module Invidious::Routes::API::V1::Authenticated def self.mark_unwatched(env) user = env.get("user").as(User) + if !user.preferences.watch_history + return error_json(409, "Watch history is disabled in preferences.") + end + id = env.params.url["id"]?.try &.as(String) if !id.match(/[a-zA-Z0-9_-]{11}/) return error_json(400, "Invalid video id.") -- cgit v1.2.3 From a5cc66e060578f801371fe3f4b53bcb3d61b3ef9 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Thu, 2 Mar 2023 16:11:50 -0500 Subject: Fix id check --- src/invidious/routes/api/v1/authenticated.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index e8e7c524..a024736c 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -81,7 +81,7 @@ module Invidious::Routes::API::V1::Authenticated return error_json(409, "Watch history is disabled in preferences.") end - id = env.params.url["id"]?.try &.as(String) + id = env.params.url["id"] if !id.match(/[a-zA-Z0-9_-]{11}/) return error_json(400, "Invalid video id.") end @@ -97,7 +97,7 @@ module Invidious::Routes::API::V1::Authenticated return error_json(409, "Watch history is disabled in preferences.") end - id = env.params.url["id"]?.try &.as(String) + id = env.params.url["id"] if !id.match(/[a-zA-Z0-9_-]{11}/) return error_json(400, "Invalid video id.") end -- cgit v1.2.3 From 025e7555420a88757aa8709419e8f09ba654854d Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Sat, 4 Mar 2023 19:14:28 -0500 Subject: Use single db call --- src/invidious/database/users.cr | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/database/users.cr b/src/invidious/database/users.cr index f8422874..d54e6a76 100644 --- a/src/invidious/database/users.cr +++ b/src/invidious/database/users.cr @@ -50,10 +50,9 @@ module Invidious::Database::Users end def mark_watched(user : User, vid : String) - mark_unwatched(user, vid) request = <<-SQL UPDATE users - SET watched = array_append(watched, $1) + SET watched = array_append(array_remove(watched, $1), $1) WHERE email = $2 SQL -- cgit v1.2.3 From d8e23d34b63b8f4f34da5c6b4bddf6eb46a3a828 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 7 Mar 2023 11:38:09 -0500 Subject: add song title for music tracks --- locales/en-US.json | 1 + src/invidious/jsonify/api_v1/video_json.cr | 15 +++++++++++++++ src/invidious/videos.cr | 2 +- src/invidious/videos/music.cr | 3 ++- src/invidious/videos/parser.cr | 5 ++++- src/invidious/views/watch.ecr | 7 ++++--- 6 files changed, 27 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 86b83a23..65a81ab7 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -190,6 +190,7 @@ "Blacklisted regions: ": "Blacklisted regions: ", "Music in this video": "Music in this video", "Artist: ": "Artist: ", + "Song: ": "Song: ", "Album: ": "Album: ", "Shared `x`": "Shared `x`", "Premieres in `x`": "Premieres in `x`", diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index a2b1a35c..fe4b5223 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -197,6 +197,21 @@ module Invidious::JSONify::APIv1 end end + if !video.music.empty? + json.field "musicTracks" do + json.array do + video.music.each do |music| + json.object do + json.field "song", music.song + json.field "artist", music.artist + json.field "album", music.album + json.field "license", music.license + end + end + end + end + end + json.field "recommendedVideos" do json.array do video.related_videos.each do |rv| diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 436ac82d..86f5ada4 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -249,7 +249,7 @@ struct Video def music : Array(VideoMusic) info["music"].as_a.map { |music_json| - VideoMusic.new(music_json["album"].as_s, music_json["artist"].as_s, music_json["license"].as_s) + VideoMusic.new(music_json["song"].as_s, music_json["album"].as_s, music_json["artist"].as_s, music_json["license"].as_s) } end diff --git a/src/invidious/videos/music.cr b/src/invidious/videos/music.cr index 402ae46f..08d88a3e 100644 --- a/src/invidious/videos/music.cr +++ b/src/invidious/videos/music.cr @@ -3,10 +3,11 @@ require "json" struct VideoMusic include JSON::Serializable + property song : String property album : String property artist : String property license : String - def initialize(@album : String, @artist : String, @license : String) + def initialize(@song : String, @album : String, @artist : String, @license : String) end end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index cf43f1be..1a8c25e4 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -322,6 +322,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any music_desclist.try &.as_a.each do |music_desc| artist = nil + song = nil album = nil music_license = nil @@ -329,13 +330,15 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) if desc_title == "ARTIST" artist = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) + elsif desc_title == "SONG" + song = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) elsif desc_title == "ALBUM" album = extract_text(desc.dig?("infoRowRenderer", "defaultMetadata")) elsif desc_title == "LICENSES" music_license = extract_text(desc.dig?("infoRowRenderer", "expandedMetadata")) end end - music_list << VideoMusic.new(album.to_s, artist.to_s, music_license.to_s) + music_list << VideoMusic.new(song.to_s, album.to_s, artist.to_s, music_license.to_s) end # Author infos diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 666eb3b0..01b30f7a 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -248,9 +248,10 @@ we're going to need to do it here in order to allow for translations.
    <% video.music.each do |music| %>
    -

    <%= translate(locale, "Artist: ") %><%= music.artist %>

    -

    <%= translate(locale, "Album: ") %><%= music.album %>

    -

    <%= translate(locale, "License: ") %><%= music.license %>

    +

    <%= translate(locale, "Song: ") %><%= music.song %>

    +

    <%= translate(locale, "Artist: ") %><%= music.artist %>

    +

    <%= translate(locale, "Album: ") %><%= music.album %>

    +

    <%= translate(locale, "License: ") %><%= music.license %>

    <% end %>
    -- cgit v1.2.3 From 742c951bc9fdc6eb1e5687104e67500fb778e0ea Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 7 Mar 2023 13:06:15 -0500 Subject: support videos with multiple songs --- src/invidious/videos/parser.cr | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 1a8c25e4..722c90e8 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -322,10 +322,17 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any music_desclist.try &.as_a.each do |music_desc| artist = nil - song = nil album = nil music_license = nil + # used when multiple songs + song = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title", "simpleText") + + # used when multiple songs and the song has a link + if !song + song = music_desc.dig("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title", "runs", 0, "text") + end + music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc| desc_title = extract_text(desc.dig?("infoRowRenderer", "title")) if desc_title == "ARTIST" -- cgit v1.2.3 From 0b17f68ebacdb54e74116cf3364c8229e896eff0 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Tue, 7 Mar 2023 13:50:02 -0500 Subject: Fix input validation --- src/invidious/routes/api/v1/authenticated.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index a024736c..ce2ee812 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -82,7 +82,7 @@ module Invidious::Routes::API::V1::Authenticated end id = env.params.url["id"] - if !id.match(/[a-zA-Z0-9_-]{11}/) + if !id.match(/^[a-zA-Z0-9_-]{11}$/) return error_json(400, "Invalid video id.") end @@ -98,7 +98,7 @@ module Invidious::Routes::API::V1::Authenticated end id = env.params.url["id"] - if !id.match(/[a-zA-Z0-9_-]{11}/) + if !id.match(/^[a-zA-Z0-9_-]{11}$/) return error_json(400, "Invalid video id.") end -- cgit v1.2.3 From e3081ef1a93973fe10ba8508ad31d257d641350e Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 7 Mar 2023 14:23:08 -0500 Subject: Apply style change suggestions Co-authored-by: Samantaz Fox --- src/invidious/videos.cr | 7 ++++++- src/invidious/videos/parser.cr | 10 ++++------ 2 files changed, 10 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 86f5ada4..0038a97a 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -249,7 +249,12 @@ struct Video def music : Array(VideoMusic) info["music"].as_a.map { |music_json| - VideoMusic.new(music_json["song"].as_s, music_json["album"].as_s, music_json["artist"].as_s, music_json["license"].as_s) + VideoMusic.new( + music_json["song"].as_s, + music_json["album"].as_s, + music_json["artist"].as_s, + music_json["license"].as_s + ) } end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 722c90e8..7cfc7ea7 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -325,12 +325,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any album = nil music_license = nil - # used when multiple songs - song = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title", "simpleText") - - # used when multiple songs and the song has a link - if !song - song = music_desc.dig("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title", "runs", 0, "text") + # Used when the video has multiple songs + if song_title = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title") + # "simpleText" for plain text / "runs" when song has a link + song = song_title["simpleText"]? || song_title.dig("runs", 0, "text") end music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc| -- cgit v1.2.3 From a781cf37347e97c469eb098e95c9a80482aac1b9 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 7 Mar 2023 15:59:51 -0500 Subject: readd try as bool for isSponsor key --- src/invidious/comments.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 56622dec..b15d63d4 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -334,7 +334,8 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) elsif child["verified"]?.try &.as_bool author_name += " " end - if child["isSponsor"].as_bool + + if child["isSponsor"]?.try &.as_bool sponsor_icon = String.build do |str| str << %( Date: Tue, 7 Mar 2023 15:46:36 -0800 Subject: removed unnecessary conditionals and uninitialized variable declarations Signed-off-by: thtmnisamnstr --- src/invidious/user/imports.cr | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 7fddcc4c..757f5b13 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -31,12 +31,6 @@ struct Invidious::User end def parse_playlist_export_csv(user : User, raw_input : String) - playlist = uninitialized InvidiousPlaylist - title = uninitialized String - description = uninitialized String - visibility = uninitialized String - privacy = uninitialized PlaylistPrivacy - # Split the input into head and body content raw_head, raw_body = raw_input.split("\n\n", limit: 2, remove_empty: true) @@ -53,13 +47,8 @@ struct Invidious::User privacy = PlaylistPrivacy::Private end - if title && privacy && user - playlist = create_playlist(title, privacy, user) - end - - if playlist && description - Invidious::Database::Playlists.update_description(playlist.id, description) - end + playlist = create_playlist(title, privacy, user) + Invidious::Database::Playlists.update_description(playlist.id, description) # Add each video to the playlist from the body content CSV.each_row(raw_body) do |row| -- cgit v1.2.3 From 3848c3f53f230e971eb67b1317f2cd4ad1b76176 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Sun, 12 Mar 2023 18:36:03 -0400 Subject: Update src/invidious/routes/video_playback.cr Co-authored-by: Samantaz Fox --- src/invidious/routes/video_playback.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index f24c0ded..9641e01a 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -278,7 +278,7 @@ module Invidious::Routes::VideoPlayback end if itag.nil? - fmt = video.fmt_stream[-1] + fmt = video.fmt_stream[-1]? else fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } end -- cgit v1.2.3 From ffcc837c2adcb4faac104c08c32060a475730e2b Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 12 Mar 2023 18:50:01 -0400 Subject: remove music license --- src/invidious/views/watch.ecr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 01b30f7a..ce92a546 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -251,7 +251,6 @@ we're going to need to do it here in order to allow for translations.

    <%= translate(locale, "Song: ") %><%= music.song %>

    <%= translate(locale, "Artist: ") %><%= music.artist %>

    <%= translate(locale, "Album: ") %><%= music.album %>

    -

    <%= translate(locale, "License: ") %><%= music.license %>

    <% end %>
    -- cgit v1.2.3 From b66a5c40a97de2816f43b7b6c816f20252eb4cbc Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 15 Mar 2023 22:37:07 +0100 Subject: Community: Restore thumbnail qualities array --- src/invidious/channels/community.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index da8be6ea..ce34ff82 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -77,9 +77,10 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) json.field "author", author json.field "authorThumbnails" do json.array do + qualities = {32, 48, 76, 100, 176, 512} author_thumbnail = post["authorThumbnail"]["thumbnails"].as_a[0]["url"].as_s - IMAGE_QUALITIES.each do |quality| + qualities.each do |quality| json.object do json.field "url", author_thumbnail.gsub(/s\d+-/, "s#{quality}-") json.field "width", quality -- cgit v1.2.3 From 4ae158ef6dcb89c2cd0eec646a42f11ebc207fba Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 19 Mar 2023 22:44:59 +0100 Subject: Videos: Add back support for views on livestreams --- src/invidious/videos/parser.cr | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 04ee7303..efc4b2e5 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -185,10 +185,12 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # We have to try to extract viewCount from videoPrimaryInfoRenderer first, # then from videoDetails, as the latter is "0" for livestreams (we want # to get the amount of viewers watching). - views_txt = video_primary_renderer - .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount", "simpleText") - views_txt ||= video_details["viewCount"]? - views = views_txt.try &.as_s.gsub(/\D/, "").to_i64? + views_txt = extract_text( + video_primary_renderer + .try &.dig?("viewCount", "videoViewCountRenderer", "viewCount") + ) + views_txt ||= video_details["viewCount"]?.try &.as_s || "" + views = views_txt.gsub(/\D/, "").to_i64? length_txt = (microformat["lengthSeconds"]? || video_details["lengthSeconds"]) .try &.as_s.to_i64 -- cgit v1.2.3 From 3492485789ae3758f551916b406ed75b3c028021 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 21 Mar 2023 21:24:37 -0400 Subject: Fix channel search --- src/invidious/yt_backend/extractors.cr | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b14ad7b9..090944fc 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -18,6 +18,7 @@ private ITEM_PARSERS = { Parsers::CategoryRendererParser, Parsers::RichItemRendererParser, Parsers::ReelItemRendererParser, + Parsers::ItemSectionRendererParser, Parsers::ContinuationItemRendererParser, } @@ -377,6 +378,30 @@ private module Parsers end end + # Parses an InnerTube itemSectionRenderer into a SearchVideo. + # Returns nil when the given object isn't a ItemSectionRenderer + # + # A itemSectionRenderer seems to be a simple wrapper for a videoRenderer, used + # by the result page for channel searches. It is located inside a continuationItems + # container.It is very similar to RichItemRendererParser + # + module ItemSectionRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item.dig?("itemSectionRenderer", "contents", 0) + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + child = VideoRendererParser.process(item_contents, author_fallback) + return child + end + + def self.parser_name + return {{@type.name}} + end + end + # Parses an InnerTube richItemRenderer into a SearchVideo. # Returns nil when the given object isn't a RichItemRenderer # -- cgit v1.2.3 From 5767344746fb9806e57cacb36ddc66ee2eaffd9e Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 21 Mar 2023 23:47:52 -0400 Subject: Fix parsing shorts on channel page --- src/invidious/channels/videos.cr | 31 +++----------------- src/invidious/yt_backend/extractors.cr | 53 +++++++++++++++++----------------- 2 files changed, 30 insertions(+), 54 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index befec03d..fc2d1044 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -127,38 +127,15 @@ module Invidious::Channel::Tabs # Shorts # ------------------- - private def fetch_shorts_data(ucid : String, continuation : String? = nil) + def get_shorts(channel : AboutChannel, continuation : String? = nil) if continuation.nil? # EgZzaG9ydHPyBgUKA5oBAA%3D%3D is the protobuf object to load "shorts" # TODO: try to extract the continuation tokens that allows other sorting options - return YoutubeAPI.browse(ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D") + initial_data = YoutubeAPI.browse(channel.ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D") else - return YoutubeAPI.browse(continuation: continuation) - end - end - - def get_shorts(channel : AboutChannel, continuation : String? = nil) - initial_data = self.fetch_shorts_data(channel.ucid, continuation) - - begin - # Try to parse the initial data fetched above - return extract_items(initial_data, channel.author, channel.ucid) - rescue ex : RetryOnceException - # Sometimes, for a completely unknown reason, the "reelItemRenderer" - # object is missing some critical information (it happens once in about - # 20 subsequent requests). Refreshing the page is required to properly - # show the "shorts" tab. - # - # In order to make the experience smoother for the user, we simulate - # said page refresh by fetching again the JSON. If that still doesn't - # work, we raise a BrokenTubeException, as something is really broken. - begin - initial_data = self.fetch_shorts_data(channel.ucid, continuation) - return extract_items(initial_data, channel.author, channel.ucid) - rescue ex : RetryOnceException - raise BrokenTubeException.new "reelPlayerHeaderSupportedRenderers" - end + initial_data = YoutubeAPI.browse(continuation: continuation) end + return extract_items(initial_data, channel.author, channel.ucid) end # ------------------- diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b14ad7b9..f952e767 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -423,42 +423,41 @@ private module Parsers "overlay", "reelPlayerOverlayRenderer" ) - # Sometimes, the "reelPlayerOverlayRenderer" object is missing the - # important part of the response. We use this exception to tell - # the calling function to fetch the content again. - if !reel_player_overlay.as_h.has_key?("reelPlayerHeaderSupportedRenderers") - raise RetryOnceException.new - end - - video_details_container = reel_player_overlay.dig( - "reelPlayerHeaderSupportedRenderers", - "reelPlayerHeaderRenderer" - ) - - # Author infos + if video_details_container = reel_player_overlay.dig?( + "reelPlayerHeaderSupportedRenderers", + "reelPlayerHeaderRenderer" + ) + # Author infos - author = video_details_container - .dig?("channelTitleText", "runs", 0, "text") - .try &.as_s || author_fallback.name + author = video_details_container + .dig?("channelTitleText", "runs", 0, "text") + .try &.as_s || author_fallback.name - ucid = video_details_container - .dig?("channelNavigationEndpoint", "browseEndpoint", "browseId") - .try &.as_s || author_fallback.id + ucid = video_details_container + .dig?("channelNavigationEndpoint", "browseEndpoint", "browseId") + .try &.as_s || author_fallback.id - # Title & publication date + # Title & publication date - title = video_details_container.dig?("reelTitleText") - .try { |t| extract_text(t) } || "" + title = video_details_container.dig?("reelTitleText") + .try { |t| extract_text(t) } || "" - published = video_details_container - .dig?("timestampText", "simpleText") - .try { |t| decode_date(t.as_s) } || Time.utc + published = video_details_container + .dig?("timestampText", "simpleText") + .try { |t| decode_date(t.as_s) } || Time.utc + # View count + view_count_text = video_details_container.dig?("viewCountText", "simpleText") + else + author = author_fallback.name + ucid = author_fallback.id + published = Time.utc + title = item_contents.dig?("headline", "simpleText").try &.as_s || "" + end # View count # View count used to be in the reelWatchEndpoint, but that changed? - view_count_text = item_contents.dig?("viewCountText", "simpleText") - view_count_text ||= video_details_container.dig?("viewCountText", "simpleText") + view_count_text ||= item_contents.dig?("viewCountText", "simpleText") view_count = view_count_text.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 -- cgit v1.2.3 From 49ddf8b6bdb98c7a9678cbec800c45350a54a786 Mon Sep 17 00:00:00 2001 From: techmetx11 Date: Thu, 23 Mar 2023 05:10:21 +0000 Subject: Added attributed description support --- src/invidious/videos/description.cr | 81 +++++++++++++++++++++++++++++++++++++ src/invidious/videos/parser.cr | 6 ++- 2 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 src/invidious/videos/description.cr (limited to 'src') diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr new file mode 100644 index 00000000..d4c60a84 --- /dev/null +++ b/src/invidious/videos/description.cr @@ -0,0 +1,81 @@ +require "json" +require "uri" + +def parse_command(command : JSON::Any?, string : String) : String? + on_tap = command.dig?("onTap", "innertubeCommand") + + # 3rd party URL, extract original URL from YouTube tracking URL + if url_endpoint = on_tap.try &.["urlEndpoint"]? + youtube_url = URI.parse url_endpoint["url"].as_s + + original_url = youtube_url.query_params["q"]? + if original_url.nil? + return "" + else + return "#{original_url}" + end + # 1st party watch URL + elsif watch_endpoint = on_tap.try &.["watchEndpoint"]? + video_id = watch_endpoint["videoId"].as_s + time = watch_endpoint["startTimeSeconds"].as_i + + url = "/watch?v=#{video_id}&t=#{time}s" + + # if text is a timestamp, use the string instead + if /(?:\d{2}:){1,2}\d{2}/ =~ string + return "#{string}" + else + return "#{url}" + end + # hashtag/other browse URLs + elsif browse_endpoint = on_tap.try &.dig?("commandMetadata", "webCommandMetadata") + url = browse_endpoint["url"].try &.as_s + + # remove unnecessary character in a channel name + if browse_endpoint["webPageType"]?.try &.as_s == "WEB_PAGE_TYPE_CHANNEL" + name = string.match(/@[\w\d]+/) + if name.try &.[0]? + return "#{name.try &.[0]}" + end + end + + return "#{string}" + end + + return "(unknown YouTube desc command)" +end + +def parse_description(desc : JSON::Any?) : String? + if desc.nil? + return "" + end + + content = desc["content"].as_s + if content.empty? + return "" + end + + if commands = desc["commandRuns"]?.try &.as_a + description = String.build do |str| + index = 0 + commands.each do |command| + start_index = command["startIndex"].as_i + length = command["length"].as_i + + if start_index > 0 && start_index - index > 0 + str << content[index..(start_index - 1)] + index += start_index - index + end + + str << parse_command(command, content[start_index, length]) + index += length + end + if index < content.size + str << content[index..content.size] + end + end + return description + end + + return content +end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 608ae99d..3a342a95 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -284,8 +284,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any description = microformat.dig?("description", "simpleText").try &.as_s || "" short_description = player_response.dig?("videoDetails", "shortDescription") - description_html = video_secondary_renderer.try &.dig?("description", "runs") - .try &.as_a.try { |t| content_to_comment_html(t, video_id) } + # description_html = video_secondary_renderer.try &.dig?("description", "runs") + # .try &.as_a.try { |t| content_to_comment_html(t, video_id) } + + description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription")) # Video metadata -- cgit v1.2.3 From 7755ed4ac8812377da04cff951324ab31d2e621c Mon Sep 17 00:00:00 2001 From: techmetx11 Date: Thu, 23 Mar 2023 20:12:54 +0000 Subject: Fix regexs --- src/invidious/videos/description.cr | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index d4c60a84..3d25197b 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -21,8 +21,9 @@ def parse_command(command : JSON::Any?, string : String) : String? url = "/watch?v=#{video_id}&t=#{time}s" - # if text is a timestamp, use the string instead - if /(?:\d{2}:){1,2}\d{2}/ =~ string + # if string is a timestamp, use the string instead + # this is a lazy regex for validating timestamps + if /(?:\d{1,2}:){1,2}\d{2}/ =~ string return "#{string}" else return "#{url}" @@ -33,7 +34,7 @@ def parse_command(command : JSON::Any?, string : String) : String? # remove unnecessary character in a channel name if browse_endpoint["webPageType"]?.try &.as_s == "WEB_PAGE_TYPE_CHANNEL" - name = string.match(/@[\w\d]+/) + name = string.match(/@[\w\d.-]+/) if name.try &.[0]? return "#{name.try &.[0]}" end -- cgit v1.2.3 From f840addd930945141a6d4fdf7e7eb8376411d82d Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Mon, 27 Mar 2023 22:10:28 -0400 Subject: Fix error when song title is missing from the track --- src/invidious/videos/parser.cr | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 608ae99d..13ee5f65 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -330,7 +330,10 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # Used when the video has multiple songs if song_title = music_desc.dig?("carouselLockupRenderer", "videoLockup", "compactVideoRenderer", "title") # "simpleText" for plain text / "runs" when song has a link - song = song_title["simpleText"]? || song_title.dig("runs", 0, "text") + song = song_title["simpleText"]? || song_title.dig?("runs", 0, "text") + + # some videos can have empty tracks. See: https://www.youtube.com/watch?v=eBGIQ7ZuuiU + next if !song end music_desc.dig?("carouselLockupRenderer", "infoRows").try &.as_a.each do |desc| -- cgit v1.2.3 From a3da03bee91eab5c602882c4b43b959362ee441d Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Thu, 23 Mar 2023 18:10:53 -0400 Subject: improve accessibility --- assets/css/default.css | 29 ++++++++++++++++++------- assets/css/embed.css | 3 ++- assets/js/_helpers.js | 8 ++++--- assets/js/handlers.js | 2 +- assets/js/player.js | 2 +- src/invidious/comments.cr | 6 ++--- src/invidious/mixes.cr | 2 +- src/invidious/playlists.cr | 2 +- src/invidious/views/components/channel_info.ecr | 4 ++-- src/invidious/views/components/item.ecr | 10 ++++----- src/invidious/views/feeds/history.ecr | 2 +- src/invidious/views/watch.ecr | 4 ++-- 12 files changed, 45 insertions(+), 29 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index f8b1c9f7..65d03be1 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -119,13 +119,16 @@ body a.pure-button { button.pure-button-primary, body a.pure-button-primary, -.channel-owner:hover { +.channel-owner:hover, +.channel-owner:focus { background-color: #a0a0a0; color: rgba(35, 35, 35, 1); } button.pure-button-primary:hover, -body a.pure-button-primary:hover { +body a.pure-button-primary:hover, +button.pure-button-primary:focus, +body a.pure-button-primary:focus { background-color: rgba(0, 182, 240, 1); color: #fff; } @@ -227,6 +230,7 @@ div.watched-indicator { border-radius: 0; box-shadow: none; + appearance: none; -webkit-appearance: none; } @@ -365,11 +369,14 @@ span > select { .light-theme a:hover, .light-theme a:active, -.light-theme summary:hover { +.light-theme summary:hover, +.light-theme a:focus, +.light-theme summary:focus { color: #075A9E !important; } -.light-theme a.pure-button-primary:hover { +.light-theme a.pure-button-primary:hover, +.light-theme a.pure-button-primary:focus { color: #fff !important; } @@ -392,11 +399,14 @@ span > select { @media (prefers-color-scheme: light) { .no-theme a:hover, .no-theme a:active, - .no-theme summary:hover { + .no-theme summary:hover, + .no-theme a:focus, + .no-theme summary:focus { color: #075A9E !important; } - .no-theme a.pure-button-primary:hover { + .no-theme a.pure-button-primary:hover, + .no-theme a.pure-button-primary:focus { color: #fff !important; } @@ -423,7 +433,9 @@ span > select { .dark-theme a:hover, .dark-theme a:active, -.dark-theme summary:hover { +.dark-theme summary:hover, +.dark-theme a:focus, +.dark-theme summary:focus { color: rgb(0, 182, 240); } @@ -462,7 +474,8 @@ body.dark-theme { @media (prefers-color-scheme: dark) { .no-theme a:hover, - .no-theme a:active { + .no-theme a:active, + .no-theme a:focus { color: rgb(0, 182, 240); } diff --git a/assets/css/embed.css b/assets/css/embed.css index 466a284a..cbafcfea 100644 --- a/assets/css/embed.css +++ b/assets/css/embed.css @@ -21,6 +21,7 @@ color: white; } -.watch-on-invidious > a:hover { +.watch-on-invidious > a:hover, +.watch-on-invidious > a:focus { color: rgba(0, 182, 240, 1);; } diff --git a/assets/js/_helpers.js b/assets/js/_helpers.js index 7c50670e..3960cf2c 100644 --- a/assets/js/_helpers.js +++ b/assets/js/_helpers.js @@ -6,6 +6,7 @@ Array.prototype.find = Array.prototype.find || function (condition) { return this.filter(condition)[0]; }; + Array.from = Array.from || function (source) { return Array.prototype.slice.call(source); }; @@ -201,15 +202,16 @@ window.helpers = window.helpers || { if (localStorageIsUsable) { return { get: function (key) { - if (!localStorage[key]) return; + let storageItem = localStorage.getItem(key) + if (!storageItem) return; try { - return JSON.parse(decodeURIComponent(localStorage[key])); + return JSON.parse(decodeURIComponent(storageItem)); } catch(e) { // Erase non parsable value helpers.storage.remove(key); } }, - set: function (key, value) { localStorage[key] = encodeURIComponent(JSON.stringify(value)); }, + set: function (key, value) { localStorage.setItem(key, encodeURIComponent(JSON.stringify(value))); }, remove: function (key) { localStorage.removeItem(key); } }; } diff --git a/assets/js/handlers.js b/assets/js/handlers.js index 29810e72..539974fb 100644 --- a/assets/js/handlers.js +++ b/assets/js/handlers.js @@ -137,7 +137,7 @@ if (focused_tag === 'textarea') return; if (focused_tag === 'input') { let focused_type = document.activeElement.type.toLowerCase(); - if (!focused_type.match(allowed)) return; + if (!allowed.test(focused_type)) return; } // Focus search bar on '/' diff --git a/assets/js/player.js b/assets/js/player.js index ee678663..bb53ac24 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -261,7 +261,7 @@ function updateCookie(newVolume, newSpeed) { var date = new Date(); date.setFullYear(date.getFullYear() + 2); - var ipRegex = /^((\d+\.){3}\d+|[A-Fa-f0-9]*:[A-Fa-f0-9:]*:[A-Fa-f0-9:]+)$/; + var ipRegex = /^((\d+\.){3}\d+|[\dA-Fa-f]*:[\d:A-Fa-f]*:[\d:A-Fa-f]+)$/; var domainUsed = location.hostname; // Fix for a bug in FF where the leading dot in the FQDN is not ignored diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index b15d63d4..2d62580d 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -346,7 +346,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML
    - +

    @@ -367,7 +367,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML

    - +
    END_HTML @@ -428,7 +428,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML
    - +
    diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index 3f342b92..defbbc84 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -97,7 +97,7 @@ def template_mix(mix)
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    #{video["title"]}

    diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 57f1f53e..40bb244b 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -507,7 +507,7 @@ def template_playlist(playlist)
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    #{video["title"]}

    diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr index f216359f..d94ecdad 100644 --- a/src/invidious/views/components/channel_info.ecr +++ b/src/invidious/views/components/channel_info.ecr @@ -1,6 +1,6 @@ <% if channel.banner %>
    - "> + " alt="">
    @@ -11,7 +11,7 @@
    - + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index fa12374f..36e9d45b 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -7,7 +7,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - "/> + " alt=""/>
    <% end %>

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    @@ -25,7 +25,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - "/> + " alt="" />

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -38,7 +38,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> @@ -58,7 +58,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if plid_form = env.get?("remove_playlist_items") %> " method="post"> @@ -112,7 +112,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if env.get? "show_watched" %> " method="post"> "> diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr index 471d21db..be1b521d 100644 --- a/src/invidious/views/feeds/history.ecr +++ b/src/invidious/views/feeds/history.ecr @@ -34,7 +34,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + " method="post"> ">

    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index a3ec94e8..d2082557 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -208,7 +208,7 @@ we're going to need to do it here in order to allow for translations.

    @@ -298,7 +298,7 @@ we're going to need to do it here in order to allow for translations. &listen=<%= params.listen %>"> <% if !env.get("preferences").as(Preferences).thin_mode %>
    - /mqdefault.jpg"> + /mqdefault.jpg" alt="">

    <%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %>

    <% end %> -- cgit v1.2.3 From 73d2ed6f77308dd300e68f3ea059c6aa2c10b1ce Mon Sep 17 00:00:00 2001 From: techmetx11 Date: Wed, 29 Mar 2023 23:33:23 +0000 Subject: Optimize some redundant stuff --- src/invidious/videos/description.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index 3d25197b..b1d851d3 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -64,8 +64,8 @@ def parse_description(desc : JSON::Any?) : String? length = command["length"].as_i if start_index > 0 && start_index - index > 0 - str << content[index..(start_index - 1)] - index += start_index - index + str << content[index...start_index] + index = start_index end str << parse_command(command, content[start_index, length]) -- cgit v1.2.3 From 0fe1b1ec19d8bf108765842dc84252fc3b394a9b Mon Sep 17 00:00:00 2001 From: Jarek Baran Date: Thu, 30 Mar 2023 12:52:03 +0200 Subject: download_widget: Add missing translation key --- locales/en-US.json | 1 + locales/pl.json | 1 + src/invidious/frontend/watch_page.cr | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index a3c195ff..05811f27 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -402,6 +402,7 @@ "Movies": "Movies", "Download": "Download", "Download as: ": "Download as: ", + "Download is disabled": "Download is disabled", "%A %B %-d, %Y": "%A %B %-d, %Y", "(edited)": "(edited)", "YouTube comment permalink": "YouTube comment permalink", diff --git a/locales/pl.json b/locales/pl.json index 3ca78e43..3c713e70 100644 --- a/locales/pl.json +++ b/locales/pl.json @@ -317,6 +317,7 @@ "Movies": "Filmy", "Download": "Pobierz", "Download as: ": "Pobierz jako: ", + "Download is disabled": "Pobieranie jest wyłączone", "%A %B %-d, %Y": "%A, %-d %B %Y", "(edited)": "(edytowany)", "YouTube comment permalink": "Odnośnik bezpośredni do komentarza na YouTube", diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index a9b00860..e3214469 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -20,7 +20,7 @@ module Invidious::Frontend::WatchPage def download_widget(locale : String, video : Video, video_assets : VideoAssets) : String if CONFIG.disabled?("downloads") - return "

    #{translate(locale, "Download is disabled.")}

    " + return "

    #{translate(locale, "Download is disabled")}

    " end return String.build(4000) do |str| -- cgit v1.2.3 From e0600f455393ffcf0edd2f0c4b644fac7dba209f Mon Sep 17 00:00:00 2001 From: Emilien Devos Date: Fri, 31 Mar 2023 22:08:09 +0200 Subject: quick fix for channel videos page --- src/invidious/channels/videos.cr | 4 +++- src/invidious/yt_backend/extractors.cr | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index befec03d..3d53f2ab 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -30,7 +30,9 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so "15:embedded" => { "1:embedded" => { "1:string" => object_inner_2_encoded, - "2:string" => "00000000-0000-0000-0000-000000000000", + }, + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", }, "3:varint" => sort_by_numerical, }, diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b14ad7b9..978e380d 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -773,6 +773,7 @@ end def extract_items(initial_data : InitialData, &block) if unpackaged_data = initial_data["contents"]?.try &.as_h elsif unpackaged_data = initial_data["response"]?.try &.as_h + elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 1).try &.as_h elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 0).try &.as_h else unpackaged_data = initial_data -- cgit v1.2.3 From 1da00bade3d370711c670afb38dcd0f97e9dd965 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 2 Apr 2023 16:31:59 -0400 Subject: implement code suggestions Co-Authored-By: Samantaz Fox --- assets/js/_helpers.js | 5 ++++- src/invidious/comments.cr | 8 ++++---- src/invidious/mixes.cr | 2 +- src/invidious/playlists.cr | 2 +- src/invidious/views/components/channel_info.ecr | 4 ++-- src/invidious/views/components/item.ecr | 8 ++++---- src/invidious/views/feeds/history.ecr | 2 +- src/invidious/views/watch.ecr | 4 ++-- 8 files changed, 19 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/assets/js/_helpers.js b/assets/js/_helpers.js index 3960cf2c..8e18169e 100644 --- a/assets/js/_helpers.js +++ b/assets/js/_helpers.js @@ -211,7 +211,10 @@ window.helpers = window.helpers || { helpers.storage.remove(key); } }, - set: function (key, value) { localStorage.setItem(key, encodeURIComponent(JSON.stringify(value))); }, + set: function (key, value) { + let encoded_value = encodeURIComponent(JSON.stringify(value)) + localStorage.setItem(key, encoded_value); + }, remove: function (key) { localStorage.removeItem(key); } }; } diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 2d62580d..fd2be73d 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -346,7 +346,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML
    - +

    @@ -367,7 +367,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML

    - +
    END_HTML @@ -428,7 +428,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML
    - +
    @@ -702,7 +702,7 @@ def content_to_comment_html(content, video_id : String? = "") str << %(title=") << emojiAlt << "\" " str << %(width=") << emojiThumb["width"] << "\" " str << %(height=") << emojiThumb["height"] << "\" " - str << %(class="channel-emoji"/>) + str << %(class="channel-emoji" />) end else # Hide deleted channel emoji diff --git a/src/invidious/mixes.cr b/src/invidious/mixes.cr index defbbc84..823ca85b 100644 --- a/src/invidious/mixes.cr +++ b/src/invidious/mixes.cr @@ -97,7 +97,7 @@ def template_mix(mix)
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    #{video["title"]}

    diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 40bb244b..013be268 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -507,7 +507,7 @@ def template_playlist(playlist)
  • - +

    #{recode_length_seconds(video["lengthSeconds"].as_i)}

    #{video["title"]}

    diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr index d94ecdad..59888760 100644 --- a/src/invidious/views/components/channel_info.ecr +++ b/src/invidious/views/components/channel_info.ecr @@ -1,6 +1,6 @@ <% if channel.banner %>
    - " alt=""> + " alt="" />
    @@ -11,7 +11,7 @@
    - + <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 36e9d45b..7cfd38db 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -7,7 +7,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - " alt=""/> + " alt="" />
    <% end %>

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    @@ -38,7 +38,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> @@ -58,7 +58,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if plid_form = env.get?("remove_playlist_items") %> " method="post"> @@ -112,7 +112,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if env.get? "show_watched" %> " method="post"> "> diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr index be1b521d..2234b297 100644 --- a/src/invidious/views/feeds/history.ecr +++ b/src/invidious/views/feeds/history.ecr @@ -34,7 +34,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + " method="post"> ">

    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index d2082557..5b3190f3 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -208,7 +208,7 @@ we're going to need to do it here in order to allow for translations.

    @@ -298,7 +298,7 @@ we're going to need to do it here in order to allow for translations. &listen=<%= params.listen %>"> <% if !env.get("preferences").as(Preferences).thin_mode %>
    - /mqdefault.jpg" alt=""> + /mqdefault.jpg" alt="" />

    <%= recode_length_seconds(rv["length_seconds"]?.try &.to_i? || 0) %>

    <% end %> -- cgit v1.2.3 From e3c1cb3ec9d40b587435020a9e53ec477e69a7ae Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 2 Apr 2023 16:45:34 -0400 Subject: fix view count extraction --- src/invidious/yt_backend/extractors.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 347d2482..9c041361 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -484,7 +484,7 @@ private module Parsers # View count used to be in the reelWatchEndpoint, but that changed? view_count_text ||= item_contents.dig?("viewCountText", "simpleText") - view_count = view_count_text.try &.as_s.gsub(/\D+/, "").to_i64? || 0_i64 + view_count = short_text_to_number(view_count_text.try &.as_s || "0") # Duration -- cgit v1.2.3 From 600da635b78f3cabee327361866f1ff0c78c0438 Mon Sep 17 00:00:00 2001 From: raphj Date: Sun, 2 Apr 2023 23:36:06 +0200 Subject: Allow browser suggestions for search (#3704) --- src/invidious/views/components/search_box.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr index 1240e5bd..a03785d1 100644 --- a/src/invidious/views/components/search_box.ecr +++ b/src/invidious/views/components/search_box.ecr @@ -1,6 +1,6 @@
    - autofocus<% end %> name="q" placeholder="<%= translate(locale, "search") %>" title="<%= translate(locale, "search") %>" -- cgit v1.2.3 From fffdaa1410db7f9b5c67b1b47d401a2744e7b220 Mon Sep 17 00:00:00 2001 From: thtmnisamnstr Date: Mon, 3 Apr 2023 17:07:58 -0700 Subject: Updated csv reading as per feedback and ran Signed-off-by: thtmnisamnstr --- src/invidious/user/imports.cr | 53 +++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 27 deletions(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 757f5b13..673991f7 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -40,7 +40,7 @@ struct Invidious::User title = csv_head[4] description = csv_head[5] visibility = csv_head[6] - + if visibility.compare("Public", case_insensitive: true) == 0 privacy = PlaylistPrivacy::Public else @@ -51,34 +51,33 @@ struct Invidious::User Invidious::Database::Playlists.update_description(playlist.id, description) # Add each video to the playlist from the body content - CSV.each_row(raw_body) do |row| - if row.size >= 1 - video_id = row[0] - if playlist - next if !video_id - next if video_id == "Video Id" - - begin - video = get_video(video_id) - rescue ex - next - 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), - }) + csv_body = CSV.new(raw_body, headers: true) + csv_body.each do |row| + video_id = row[0] + if playlist + next if !video_id + next if video_id == "Video Id" - Invidious::Database::PlaylistVideos.insert(playlist_video) - Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) + begin + video = get_video(video_id) + rescue ex + next 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), + }) + + Invidious::Database::PlaylistVideos.insert(playlist_video) + Invidious::Database::Playlists.update_video_added(playlist.id, playlist_video.index) end end -- cgit v1.2.3 From b3c0afef02ee13c7f291fd26a5d64b4aee059906 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 5 Apr 2023 23:43:41 +0200 Subject: Videos: fix description text offset when emojis are present --- src/invidious/videos/description.cr | 71 ++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 24 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index b1d851d3..2017955d 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -46,37 +46,60 @@ def parse_command(command : JSON::Any?, string : String) : String? return "(unknown YouTube desc command)" end -def parse_description(desc : JSON::Any?) : String? - if desc.nil? - return "" +private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int + copied = 0 + while copied < count + cp = iter.next + break if cp.is_a?(Iterator::Stop) + + str << cp.chr + + # A codepoint from the SMP counts twice + copied += 1 if cp > 0xFFFF + copied += 1 end + return copied +end + +def parse_description(desc : JSON::Any?) : String? + return "" if desc.nil? + content = desc["content"].as_s - if content.empty? - return "" - end + return "" if content.empty? - if commands = desc["commandRuns"]?.try &.as_a - description = String.build do |str| - index = 0 - commands.each do |command| - start_index = command["startIndex"].as_i - length = command["length"].as_i + commands = desc["commandRuns"]?.try &.as_a + return content if commands.nil? - if start_index > 0 && start_index - index > 0 - str << content[index...start_index] - index = start_index - end + # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints + # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are + # automatically decoded by the JSON parser. It means that we need to count + # copied byte in a special manner, preventing the use of regular string copy. + iter = content.each_codepoint - str << parse_command(command, content[start_index, length]) - index += length - end - if index < content.size - str << content[index..content.size] + index = 0 + + return String.build do |str| + commands.each do |command| + cmd_start = command["startIndex"].as_i + cmd_length = command["length"].as_i + + # Copy the text chunk between this command and the previous if needed. + length = cmd_start - index + index += copy_string(str, iter, length) + + # We need to copy the command's text using the iterator + # and the special function defined above. + cmd_content = String.build(cmd_length) do |str2| + copy_string(str2, iter, cmd_length) end + + str << parse_command(command, cmd_content) + index += cmd_length end - return description - end - return content + # Copy the end of the string (past the last command). + remaining_length = content.size - index + copy_string(str, iter, remaining_length) if remaining_length > 0 + end end -- cgit v1.2.3 From 5517a4eadb980ae06d4dde08afd10fec8c83f9b4 Mon Sep 17 00:00:00 2001 From: chunky programmer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Wed, 19 Apr 2023 23:29:50 -0400 Subject: fix fetching community continuations --- src/invidious/channels/community.cr | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index ce34ff82..ad786f3a 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -31,18 +31,16 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) session_token: session_token, } - response = YT_POOL.client &.post("/comment_service_ajax?action_get_comments=1&ctoken=#{continuation}&continuation=#{continuation}&hl=en&gl=US", headers, form: post_req) - body = JSON.parse(response.body) + body = YoutubeAPI.browse(continuation) - body = body["response"]["continuationContents"]["itemSectionContinuation"]? || - body["response"]["continuationContents"]["backstageCommentsContinuation"]? + body = body.dig?("continuationContents", "itemSectionContinuation") || + body.dig?("continuationContents", "backstageCommentsContinuation") if !body raise InfoException.new("Could not extract continuation.") end end - continuation = body["continuations"]?.try &.[0]["nextContinuationData"]["continuation"].as_s posts = body["contents"].as_a if message = posts[0]["messageRenderer"]? @@ -270,10 +268,8 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - - if body["continuations"]? - continuation = body["continuations"][0]["nextContinuationData"]["continuation"].as_s - json.field "continuation", extract_channel_community_cursor(continuation) + if cont = posts.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token") + json.field "continuation", extract_channel_community_cursor(cont.as_s) end end end -- cgit v1.2.3 From 0107b774f29b0f4cc0a7fabe546db347390337ec Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 28 Apr 2023 17:23:40 +0200 Subject: Trending: Don't extract items from categories --- src/invidious/trending.cr | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 134eb437..74bab1bd 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -17,7 +17,9 @@ def fetch_trending(trending_type, region, locale) client_config = YoutubeAPI::ClientConfig.new(region: region) initial_data = YoutubeAPI.browse("FEtrending", params: params, client_config: client_config) - trending = extract_videos(initial_data) - return {trending, plid} + items, _ = extract_items(initial_data) + + # Return items, but ignore categories (e.g featured content) + return items.reject!(Category), plid end -- cgit v1.2.3 From 7afa03d821365673e955468eff58009b5fb5c4c8 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 28 Apr 2023 17:27:06 +0200 Subject: Search: Don't extract items from categories too --- src/invidious/search/processors.cr | 4 ++-- src/invidious/search/query.cr | 23 +---------------------- 2 files changed, 3 insertions(+), 24 deletions(-) (limited to 'src') diff --git a/src/invidious/search/processors.cr b/src/invidious/search/processors.cr index 7e909590..25edb936 100644 --- a/src/invidious/search/processors.cr +++ b/src/invidious/search/processors.cr @@ -10,7 +10,7 @@ module Invidious::Search initial_data = YoutubeAPI.search(query.text, search_params, client_config: client_config) items, _ = extract_items(initial_data) - return items + return items.reject!(Category) end # Search a youtube channel @@ -32,7 +32,7 @@ module Invidious::Search response_json = YoutubeAPI.browse(continuation) items, _ = extract_items(response_json, "", ucid) - return items + return items.reject!(Category) end # Search inside of user subscriptions diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr index 24e79609..e38845d9 100644 --- a/src/invidious/search/query.cr +++ b/src/invidious/search/query.cr @@ -113,7 +113,7 @@ module Invidious::Search case @type when .regular?, .playlist? - items = unnest_items(Processors.regular(self)) + items = Processors.regular(self) # when .channel? items = Processors.channel(self) @@ -136,26 +136,5 @@ module Invidious::Search return params end - - # TODO: clean code - private def unnest_items(all_items) : Array(SearchItem) - items = [] of SearchItem - - # Light processing to flatten search results out of Categories. - # They should ideally be supported in the future. - all_items.each do |i| - if i.is_a? Category - i.contents.each do |nest_i| - if !nest_i.is_a? Video - items << nest_i - end - end - else - items << i - end - end - - return items - end end end -- cgit v1.2.3 From 3cfbc19ccc031ec4640f5e06568d2a52ebf90627 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 28 Apr 2023 17:30:01 +0200 Subject: Extractors: Add utility function to extract items from categories --- src/invidious/yt_backend/extractors_utils.cr | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index 0cb3c079..b247dca8 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -68,19 +68,16 @@ rescue ex return false end -def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) : Array(SearchVideo) - extracted, _ = extract_items(initial_data, author_fallback, author_id_fallback) +# This function extracts the SearchItems from a Category. +# Categories are commonly returned in search results and trending pages. +def extract_category(category : Category) : Array(SearchVideo) + items = [] of SearchItem - target = [] of (SearchItem | Continuation) - extracted.each do |i| - if i.is_a?(Category) - i.contents.each { |cate_i| target << cate_i if !cate_i.is_a? Video } - else - target << i - end + category.contents.each do |item| + target << cate_i if item.is_a?(SearchItem) end - return target.select(SearchVideo) + return items end def extract_selected_tab(tabs) -- cgit v1.2.3 From f298e225a114578e0551e04d5e68f2bfcbe84e72 Mon Sep 17 00:00:00 2001 From: chunky programmer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Mon, 24 Apr 2023 19:29:34 -0400 Subject: fix live video attachments, parse playlists --- src/invidious/channels/community.cr | 68 +++++++++-------------------- src/invidious/helpers/serialized_yt_data.cr | 1 + src/invidious/yt_backend/extractors.cr | 2 +- 3 files changed, 22 insertions(+), 49 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index ad786f3a..87430305 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -123,49 +123,13 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) if attachment = post["backstageAttachment"]? json.field "attachment" do - json.object do - case attachment.as_h - when .has_key?("videoRenderer") - attachment = attachment["videoRenderer"] - json.field "type", "video" - - if !attachment["videoId"]? - error_message = (attachment["title"]["simpleText"]? || - attachment["title"]["runs"]?.try &.[0]?.try &.["text"]?) - - json.field "error", error_message - else - video_id = attachment["videoId"].as_s - - video_title = attachment["title"]["simpleText"]? || attachment["title"]["runs"]?.try &.[0]?.try &.["text"]? - json.field "title", video_title - json.field "videoId", video_id - json.field "videoThumbnails" do - Invidious::JSONify::APIv1.thumbnails(json, video_id) - end - - json.field "lengthSeconds", decode_length_seconds(attachment["lengthText"]["simpleText"].as_s) - - author_info = attachment["ownerText"]["runs"][0].as_h - - json.field "author", author_info["text"].as_s - json.field "authorId", author_info["navigationEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", author_info["navigationEndpoint"]["commandMetadata"]["webCommandMetadata"]["url"] - - # TODO: json.field "authorThumbnails", "channelThumbnailSupportedRenderers" - # TODO: json.field "authorVerified", "ownerBadges" - - published = decode_date(attachment["publishedTimeText"]["simpleText"].as_s) - - json.field "published", published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) - - view_count = attachment["viewCountText"]?.try &.["simpleText"].as_s.gsub(/\D/, "").to_i64? || 0_i64 - - json.field "viewCount", view_count - json.field "viewCountText", translate_count(locale, "generic_views_count", view_count, NumberFormatting::Short) - end - when .has_key?("backstageImageRenderer") + case attachment.as_h + when .has_key?("videoRenderer") + parse_item(attachment) + .as(SearchVideo) + .to_json(locale, json) + when .has_key?("backstageImageRenderer") + json.object do attachment = attachment["backstageImageRenderer"] json.field "type", "image" @@ -186,7 +150,9 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - when .has_key?("pollRenderer") + end + when .has_key?("pollRenderer") + json.object do attachment = attachment["pollRenderer"] json.field "type", "poll" json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0]) @@ -219,7 +185,9 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - when .has_key?("postMultiImageRenderer") + end + when .has_key?("postMultiImageRenderer") + json.object do attachment = attachment["postMultiImageRenderer"] json.field "type", "multiImage" json.field "images" do @@ -243,10 +211,14 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - else - json.field "type", "unknown" - json.field "error", "Unrecognized attachment type." end + when .has_key?("playlistRenderer") + parse_item(attachment) + .as(SearchPlaylist) + .to_json(locale, json) + else + json.field "type", "unknown" + json.field "error", "Unrecognized attachment type." end end end diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index c1874780..7c12ad0e 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -84,6 +84,7 @@ struct SearchVideo json.field "descriptionHtml", self.description_html json.field "viewCount", self.views + json.field "viewCountText", translate_count(locale, "generic_views_count", self.views, NumberFormatting::Short) json.field "published", self.published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) json.field "lengthSeconds", self.length_seconds diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 9c041361..8ff4c1f9 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -268,7 +268,7 @@ private module Parsers end private def self.parse(item_contents, author_fallback) - title = item_contents["title"]["simpleText"]?.try &.as_s || "" + title = extract_text(item_contents["title"]) || "" plid = item_contents["playlistId"]?.try &.as_s || "" video_count = HelperExtractors.get_video_count(item_contents) -- cgit v1.2.3 From d420741cc15dce656da641f5143120ec88e59bc8 Mon Sep 17 00:00:00 2001 From: chunky programmer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Wed, 19 Apr 2023 20:59:06 -0400 Subject: Allow channel urls to be displayed in YT description --- src/invidious/videos/description.cr | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index 2017955d..0a9d84f8 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -6,13 +6,19 @@ def parse_command(command : JSON::Any?, string : String) : String? # 3rd party URL, extract original URL from YouTube tracking URL if url_endpoint = on_tap.try &.["urlEndpoint"]? - youtube_url = URI.parse url_endpoint["url"].as_s - - original_url = youtube_url.query_params["q"]? - if original_url.nil? - return "" + if url_endpoint["url"].as_s.includes? "youtube.com/redirect" + youtube_url = URI.parse url_endpoint["url"].as_s + original_url = youtube_url.query_params["q"]? + if original_url.nil? + return "" + else + return "#{original_url}" + end else - return "#{original_url}" + # not a redirect url, some first party url + # see https://github.com/iv-org/invidious/issues/3751 + first_party_url = url_endpoint["url"].as_s + return "#{first_party_url}" end # 1st party watch URL elsif watch_endpoint = on_tap.try &.["watchEndpoint"]? -- cgit v1.2.3 From 1b10446e5ecfb50d84fae88b6b8953ed19bfe1fb Mon Sep 17 00:00:00 2001 From: chunky programmer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Mon, 24 Apr 2023 17:40:58 -0400 Subject: move url parsing to utils method --- src/invidious/comments.cr | 51 ++------------------------------ src/invidious/helpers/utils.cr | 53 +++++++++++++++++++++++++++++++++ src/invidious/videos/description.cr | 59 ++++--------------------------------- src/invidious/videos/parser.cr | 2 +- 4 files changed, 62 insertions(+), 103 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index fd2be73d..0c863977 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -635,55 +635,8 @@ def content_to_comment_html(content, video_id : String? = "") text = HTML.escape(run["text"].as_s) - if run["navigationEndpoint"]? - if url = run["navigationEndpoint"]["urlEndpoint"]?.try &.["url"].as_s - url = URI.parse(url) - displayed_url = text - - if url.host == "youtu.be" - url = "/watch?v=#{url.request_target.lstrip('/')}" - elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") - if url.path == "/redirect" - # Sometimes, links can be corrupted (why?) so make sure to fallback - # nicely. See https://github.com/iv-org/invidious/issues/2682 - url = url.query_params["q"]? || "" - displayed_url = url - else - url = url.request_target - displayed_url = "youtube.com#{url}" - end - end - - text = %(#{reduce_uri(displayed_url)}) - elsif watch_endpoint = run["navigationEndpoint"]["watchEndpoint"]? - start_time = watch_endpoint["startTimeSeconds"]?.try &.as_i - link_video_id = watch_endpoint["videoId"].as_s - - url = "/watch?v=#{link_video_id}" - url += "&t=#{start_time}" if !start_time.nil? - - # If the current video ID (passed through from the caller function) - # is the same as the video ID in the link, add HTML attributes for - # the JS handler function that bypasses page reload. - # - # See: https://github.com/iv-org/invidious/issues/3063 - if link_video_id == video_id - start_time ||= 0 - text = %(#{reduce_uri(text)}) - else - text = %(#{text}) - end - elsif url = run.dig?("navigationEndpoint", "commandMetadata", "webCommandMetadata", "url").try &.as_s - if text.starts_with?(/\s?[@#]/) - # Handle "pings" in comments and hasthags differently - # See: - # - https://github.com/iv-org/invidious/issues/3038 - # - https://github.com/iv-org/invidious/issues/3062 - text = %(#{text}) - else - text = %(#{reduce_uri(url)}) - end - end + if navigationEndpoint = run.dig?("navigationEndpoint") + text = parse_link_endpoint(navigationEndpoint, text, video_id) end text = "#{text}" if run["bold"]? diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 500a2582..bcf7c963 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -389,3 +389,56 @@ def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = " end return str end + +# Get the html link from a NavigationEndpoint or an innertubeCommand +def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String) + if url = endpoint.dig?("urlEndpoint", "url").try &.as_s + url = URI.parse(url) + displayed_url = text + + if url.host == "youtu.be" + url = "/watch?v=#{url.request_target.lstrip('/')}" + elsif url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") + if url.path == "/redirect" + # Sometimes, links can be corrupted (why?) so make sure to fallback + # nicely. See https://github.com/iv-org/invidious/issues/2682 + url = url.query_params["q"]? || "" + displayed_url = url + else + url = url.request_target + displayed_url = "youtube.com#{url}" + end + end + + text = %(#{reduce_uri(displayed_url)}) + elsif watch_endpoint = endpoint.dig?("watchEndpoint") + start_time = watch_endpoint["startTimeSeconds"]?.try &.as_i + link_video_id = watch_endpoint["videoId"].as_s + + url = "/watch?v=#{link_video_id}" + url += "&t=#{start_time}" if !start_time.nil? + + # If the current video ID (passed through from the caller function) + # is the same as the video ID in the link, add HTML attributes for + # the JS handler function that bypasses page reload. + # + # See: https://github.com/iv-org/invidious/issues/3063 + if link_video_id == video_id + start_time ||= 0 + text = %(#{reduce_uri(text)}) + else + text = %(#{text}) + end + elsif url = endpoint.dig?("commandMetadata", "webCommandMetadata", "url").try &.as_s + if text.starts_with?(/\s?[@#]/) + # Handle "pings" in comments and hasthags differently + # See: + # - https://github.com/iv-org/invidious/issues/3038 + # - https://github.com/iv-org/invidious/issues/3062 + text = %(#{text}) + else + text = %(#{reduce_uri(url)}) + end + end + return text +end diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index 0a9d84f8..542cb416 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -1,57 +1,6 @@ require "json" require "uri" -def parse_command(command : JSON::Any?, string : String) : String? - on_tap = command.dig?("onTap", "innertubeCommand") - - # 3rd party URL, extract original URL from YouTube tracking URL - if url_endpoint = on_tap.try &.["urlEndpoint"]? - if url_endpoint["url"].as_s.includes? "youtube.com/redirect" - youtube_url = URI.parse url_endpoint["url"].as_s - original_url = youtube_url.query_params["q"]? - if original_url.nil? - return "" - else - return "#{original_url}" - end - else - # not a redirect url, some first party url - # see https://github.com/iv-org/invidious/issues/3751 - first_party_url = url_endpoint["url"].as_s - return "#{first_party_url}" - end - # 1st party watch URL - elsif watch_endpoint = on_tap.try &.["watchEndpoint"]? - video_id = watch_endpoint["videoId"].as_s - time = watch_endpoint["startTimeSeconds"].as_i - - url = "/watch?v=#{video_id}&t=#{time}s" - - # if string is a timestamp, use the string instead - # this is a lazy regex for validating timestamps - if /(?:\d{1,2}:){1,2}\d{2}/ =~ string - return "#{string}" - else - return "#{url}" - end - # hashtag/other browse URLs - elsif browse_endpoint = on_tap.try &.dig?("commandMetadata", "webCommandMetadata") - url = browse_endpoint["url"].try &.as_s - - # remove unnecessary character in a channel name - if browse_endpoint["webPageType"]?.try &.as_s == "WEB_PAGE_TYPE_CHANNEL" - name = string.match(/@[\w\d.-]+/) - if name.try &.[0]? - return "#{name.try &.[0]}" - end - end - - return "#{string}" - end - - return "(unknown YouTube desc command)" -end - private def copy_string(str : String::Builder, iter : Iterator, count : Int) : Int copied = 0 while copied < count @@ -68,7 +17,7 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I return copied end -def parse_description(desc : JSON::Any?) : String? +def parse_description(desc, video_id : String) : String? return "" if desc.nil? content = desc["content"].as_s @@ -100,7 +49,11 @@ def parse_description(desc : JSON::Any?) : String? copy_string(str2, iter, cmd_length) end - str << parse_command(command, cmd_content) + link = cmd_content + if on_tap = command.dig?("onTap", "innertubeCommand") + link = parse_link_endpoint(on_tap, cmd_content, video_id) + end + str << link index += cmd_length end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 1c6d118d..2e8eecc3 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -287,7 +287,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any # description_html = video_secondary_renderer.try &.dig?("description", "runs") # .try &.as_a.try { |t| content_to_comment_html(t, video_id) } - description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription")) + description_html = parse_description(video_secondary_renderer.try &.dig?("attributedDescription"), video_id) # Video metadata -- cgit v1.2.3 From 28584f22c52b243da740061eeb834e300f36b7c1 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 11 Apr 2023 20:50:23 -0400 Subject: Fix index out of bounds error --- src/invidious/comments.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index fd2be73d..b5815bb4 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -604,7 +604,7 @@ def text_to_parsed_content(text : String) : JSON::Any currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}} currentNodes << (JSON.parse(currentNode.to_json)) # If text remain after match create new simple node with text after match - afterNode = {"text" => splittedLastNode.size > 0 ? splittedLastNode[1] : ""} + afterNode = {"text" => splittedLastNode.size > 1 ? splittedLastNode[1] : ""} currentNodes << (JSON.parse(afterNode.to_json)) end -- cgit v1.2.3 From 384a8e200c953ed5be3ba6a01762e933fd566e45 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 2 May 2023 23:18:40 +0200 Subject: Trending: fix mistakes from #3773 --- src/invidious/trending.cr | 18 ++++++++++++++++-- src/invidious/yt_backend/extractors_utils.cr | 13 +++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index 74bab1bd..fcaf60d1 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -20,6 +20,20 @@ def fetch_trending(trending_type, region, locale) items, _ = extract_items(initial_data) - # Return items, but ignore categories (e.g featured content) - return items.reject!(Category), plid + extracted = [] of SearchItem + + items.each do |itm| + if itm.is_a?(Category) + # Ignore the smaller categories, as they generally contain a sponsored + # channel, which brings a lot of noise on the trending page. + # See: https://github.com/iv-org/invidious/issues/2989 + next if itm.contents.size < 24 + + extracted.concat extract_category(itm) + else + extracted << itm + end + end + + return extracted, plid end diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index b247dca8..11d95958 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -68,16 +68,17 @@ rescue ex return false end -# This function extracts the SearchItems from a Category. +# This function extracts SearchVideo items from a Category. # Categories are commonly returned in search results and trending pages. def extract_category(category : Category) : Array(SearchVideo) - items = [] of SearchItem + return category.contents.select(SearchVideo) +end - category.contents.each do |item| - target << cate_i if item.is_a?(SearchItem) +# :ditto: +def extract_category(category : Category, &) + category.contents.select(SearchVideo).each do |item| + yield item end - - return items end def extract_selected_tab(tabs) -- cgit v1.2.3 From 90914343ec1a4c89e8bb873fdefa0a8e8ac656df Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 3 May 2023 00:02:38 +0200 Subject: Trending: de-duplicate results --- src/invidious/trending.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/trending.cr b/src/invidious/trending.cr index fcaf60d1..2d9f8a83 100644 --- a/src/invidious/trending.cr +++ b/src/invidious/trending.cr @@ -35,5 +35,6 @@ def fetch_trending(trending_type, region, locale) end end - return extracted, plid + # Deduplicate items before returning results + return extracted.select(SearchVideo).uniq!(&.id), plid end -- cgit v1.2.3 From 2d5145614be46c0b59a87c26cecac0c4b69e3437 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 2 May 2023 21:10:57 -0400 Subject: Fix unknown type attachment Co-authored-by: Samantaz Fox --- src/invidious/channels/community.cr | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 87430305..2c7b9fec 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -217,8 +217,10 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) .as(SearchPlaylist) .to_json(locale, json) else - json.field "type", "unknown" - json.field "error", "Unrecognized attachment type." + json.object do + json.field "type", "unknown" + json.field "error", "Unrecognized attachment type." + end end end end -- cgit v1.2.3 From ce2649420fb868596bd926393fb1073d2671a4f5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 19:36:52 +0200 Subject: CSS: Fix iframe attachment size in community posts --- assets/css/default.css | 14 ++++++++++++++ src/invidious/comments.cr | 18 +++++------------- 2 files changed, 19 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 4d06b77f..23649f8f 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -330,6 +330,20 @@ p.video-data { margin: 0; font-weight: bold; font-size: 80%; } margin: auto; } +.video-iframe-wrapper { + position: relative; + height: 0; + padding-bottom: 56.25%; +} + +.video-iframe { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border: none; +} /* * Footer diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index ec4449f0..f43e39c6 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -372,27 +372,19 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false)
    END_HTML when "video" - html << <<-END_HTML -
    -
    -
    - END_HTML - if attachment["error"]? html << <<-END_HTML +

    #{attachment["error"]}

    +
    END_HTML else html << <<-END_HTML - +
    + +
    END_HTML end - - html << <<-END_HTML -
    -
    -
    - END_HTML else nil # Ignore end end -- cgit v1.2.3 From 720789b6221518fd1614debfcee794a422df9466 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 19:41:07 +0200 Subject: HTML: wrap comments metadata in a paragraph --- src/invidious/comments.cr | 2 ++ 1 file changed, 2 insertions(+) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index f43e39c6..01556099 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -390,6 +390,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end html << <<-END_HTML +

    #{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""} | END_HTML @@ -408,6 +409,7 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML #{number_with_separator(child["likeCount"])} +

    END_HTML if child["creatorHeart"]? -- cgit v1.2.3 From ce1fb8d08c86f747ee638289c8bcfeb208702445 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 8 May 2023 00:53:08 +0200 Subject: Use XML.parse instead of XML.parse_html Due to recent changes to libxml2 (between 2.9.14 and 2.10.4, See https://gitlab.gnome.org/GNOME/libxml2/-/issues/508), the HTML parser doesn't take into account the namespaces (xmlns). Because HTML shouldn't contain namespaces anyway, there is no reason for use to keep using it. But switching to the XML parser means that we have to pass the namespaces to every single 'xpath_node(s)' method for it to be able to properly navigate the XML structure. --- src/invidious/channels/channels.cr | 36 +++++++++++++++++++++++++----------- 1 file changed, 25 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 63dd2194..b09d93b1 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -159,12 +159,18 @@ def fetch_channel(ucid, pull_all_videos : Bool) LOGGER.debug("fetch_channel: #{ucid}") LOGGER.trace("fetch_channel: #{ucid} : pull_all_videos = #{pull_all_videos}") + namespaces = { + "yt" => "http://www.youtube.com/xml/schemas/2015", + "media" => "http://search.yahoo.com/mrss/", + "default" => "http://www.w3.org/2005/Atom", + } + LOGGER.trace("fetch_channel: #{ucid} : Downloading RSS feed") rss = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{ucid}").body LOGGER.trace("fetch_channel: #{ucid} : Parsing RSS feed") - rss = XML.parse_html(rss) + rss = XML.parse(rss) - author = rss.xpath_node(%q(//feed/title)) + author = rss.xpath_node("//default:feed/default:title", namespaces) if !author raise InfoException.new("Deleted or invalid channel") end @@ -192,15 +198,23 @@ def fetch_channel(ucid, pull_all_videos : Bool) videos, continuation = IV::Channel::Tabs.get_videos(channel) LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") - rss.xpath_nodes("//feed/entry").each do |entry| - video_id = entry.xpath_node("videoid").not_nil!.content - title = entry.xpath_node("title").not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) - author = entry.xpath_node("author/name").not_nil!.content - ucid = entry.xpath_node("channelid").not_nil!.content - views = entry.xpath_node("group/community/statistics").try &.["views"]?.try &.to_i64? - views ||= 0_i64 + rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry| + video_id = entry.xpath_node("yt:videoid", namespaces).not_nil!.content + title = entry.xpath_node("default:title", namespaces).not_nil!.content + + published = Time.parse_rfc3339( + entry.xpath_node("default:published", namespaces).not_nil!.content + ) + updated = Time.parse_rfc3339( + entry.xpath_node("default:updated", namespaces).not_nil!.content + ) + + author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content + ucid = entry.xpath_node("yt:channelid", namespaces).not_nil!.content + + views = entry + .xpath_node("media:group/media:community/media:statistics", namespaces) + .try &.["views"]?.try &.to_i64? || 0_i64 channel_video = videos .select(SearchVideo) -- cgit v1.2.3 From c385a944e642ce9e060c2dcf2082ecf0bb10b45a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 8 May 2023 13:10:18 +0200 Subject: Subscriptions: Fix casing of XML tag names --- src/invidious/channels/channels.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index b09d93b1..c3d6124f 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -199,7 +199,7 @@ def fetch_channel(ucid, pull_all_videos : Bool) LOGGER.trace("fetch_channel: #{ucid} : Extracting videos from channel RSS feed") rss.xpath_nodes("//default:feed/default:entry", namespaces).each do |entry| - video_id = entry.xpath_node("yt:videoid", namespaces).not_nil!.content + video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content title = entry.xpath_node("default:title", namespaces).not_nil!.content published = Time.parse_rfc3339( @@ -210,7 +210,7 @@ def fetch_channel(ucid, pull_all_videos : Bool) ) author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content - ucid = entry.xpath_node("yt:channelid", namespaces).not_nil!.content + ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content views = entry .xpath_node("media:group/media:community/media:statistics", namespaces) -- cgit v1.2.3 From 6755e31b726fa857e75ede988af216a52eab8cc7 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 14 May 2023 20:10:56 +0200 Subject: Fix hashtag continuation token --- src/invidious/hashtag.cr | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/invidious/hashtag.cr b/src/invidious/hashtag.cr index bc329205..d9d584c9 100644 --- a/src/invidious/hashtag.cr +++ b/src/invidious/hashtag.cr @@ -17,21 +17,18 @@ module Invidious::Hashtag "80226972:embedded" => { "2:string" => "FEhashtag", "3:base64" => { - "1:varint" => cursor.to_i64, - }, - "7:base64" => { - "325477796:embedded" => { - "1:embedded" => { - "2:0:embedded" => { - "2:string" => '#' + hashtag, - "4:varint" => 0_i64, - "11:string" => "", - }, - "4:string" => "browse-feedFEhashtag", - }, - "2:string" => hashtag, + "1:varint" => 60_i64, # result count + "15:base64" => { + "1:varint" => cursor.to_i64, + "2:varint" => 0_i64, + }, + "93:2:embedded" => { + "1:string" => hashtag, + "2:varint" => 0_i64, + "3:varint" => 1_i64, }, }, + "35:string" => "browse-feedFEhashtag", }, } -- cgit v1.2.3 From d6fb5c03b72b40bf7bd71f8023c71c76ea41f53d Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Thu, 16 Mar 2023 11:03:07 -0400 Subject: add hashtag endpoint --- src/invidious/routes/api/v1/search.cr | 30 ++++++++++++++++++++++++++++++ src/invidious/routing.cr | 1 + 2 files changed, 31 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index 21451d33..0bf74bc3 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -55,4 +55,34 @@ module Invidious::Routes::API::V1::Search return error_json(500, ex) end end + + def self.hashtag(env) + hashtag = env.params.url["hashtag"] + + # page does not change anything. + # page = env.params.query["page"]?.try &.to_i?|| 1 + + page = 1 + locale = env.get("preferences").as(Preferences).locale + region = env.params.query["region"]? + env.response.content_type = "application/json" + + begin + results = Invidious::Hashtag.fetch(hashtag, page, region) + rescue ex + return error_json(400, ex) + end + + JSON.build do |json| + json.object do + json.field "results" do + json.array do + results.each do |item| + item.to_json(locale, json) + end + end + end + end + end + end end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 9e2ade3d..72ee9194 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -243,6 +243,7 @@ module Invidious::Routing # Search get "/api/v1/search", {{namespace}}::Search, :search get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions + get "/api/v1/hashtag/:hashtag", {{namespace}}::Search, :hashtag # Authenticated -- cgit v1.2.3 From d7285992517c98a276e325f83e1b7584dac3c498 Mon Sep 17 00:00:00 2001 From: chunky programmer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 14 May 2023 15:20:59 -0400 Subject: add page parameter --- src/invidious/routes/api/v1/search.cr | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index 0bf74bc3..9fb283c2 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -59,10 +59,8 @@ module Invidious::Routes::API::V1::Search def self.hashtag(env) hashtag = env.params.url["hashtag"] - # page does not change anything. - # page = env.params.query["page"]?.try &.to_i?|| 1 + page = env.params.query["page"]?.try &.to_i? || 1 - page = 1 locale = env.get("preferences").as(Preferences).locale region = env.params.query["region"]? env.response.content_type = "application/json" -- cgit v1.2.3 From b2a0e6f1ffe448f8c3f6f943b34c673537210794 Mon Sep 17 00:00:00 2001 From: chunky programmer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 14 May 2023 16:49:49 -0400 Subject: Parse playlists when searching a channel --- src/invidious/yt_backend/extractors.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 8ff4c1f9..6686e6e7 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -381,7 +381,7 @@ private module Parsers # Parses an InnerTube itemSectionRenderer into a SearchVideo. # Returns nil when the given object isn't a ItemSectionRenderer # - # A itemSectionRenderer seems to be a simple wrapper for a videoRenderer, used + # A itemSectionRenderer seems to be a simple wrapper for a videoRenderer or a playlistRenderer, used # by the result page for channel searches. It is located inside a continuationItems # container.It is very similar to RichItemRendererParser # @@ -394,6 +394,8 @@ private module Parsers private def self.parse(item_contents, author_fallback) child = VideoRendererParser.process(item_contents, author_fallback) + child ||= PlaylistRendererParser.process(item_contents, author_fallback) + return child end -- cgit v1.2.3 From 12b4dd9191307c2b3387a4c73c2fc06be5da7703 Mon Sep 17 00:00:00 2001 From: chunky programmer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 14 May 2023 17:25:32 -0400 Subject: Populate search bar with ChannelId --- src/invidious/routes/channels.cr | 1 + src/invidious/routes/search.cr | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index d3969d29..740f3096 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -278,6 +278,7 @@ module Invidious::Routes::Channels return error_template(500, ex) end + env.set "search", "channel:" + ucid + " " return {locale, user, subscriptions, continuation, ucid, channel} end end diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 2a9705cf..7f17124e 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -65,7 +65,11 @@ module Invidious::Routes::Search redirect_url = Invidious::Frontend::Misc.redirect_url(env) - env.set "search", query.text + if query.type == Invidious::Search::Query::Type::Channel + env.set "search", "channel:" + query.channel + " " + query.text + else + env.set "search", query.text + end templated "search" end end -- cgit v1.2.3 From c713c32cebda5d0199b5c0dd553744f8d61707da Mon Sep 17 00:00:00 2001 From: chunky programmer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 14 May 2023 22:35:51 -0400 Subject: Fix issue where playlists will refetch the same videos --- src/invidious/routes/playlists.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 0d242ee6..8675fa45 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -410,8 +410,8 @@ module Invidious::Routes::Playlists return error_template(500, ex) end - page_count = (playlist.video_count / 100).to_i - page_count += 1 if (playlist.video_count % 100) > 0 + page_count = (playlist.video_count / 200).to_i + page_count += 1 if (playlist.video_count % 200) > 0 if page > page_count return env.redirect "/playlist?list=#{plid}&page=#{page_count}" @@ -422,7 +422,7 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(playlist, offset: (page - 1) * 100) + videos = get_playlist_videos(playlist, offset: (page - 1) * 200) rescue ex return error_template(500, "Error encountered while retrieving playlist videos.
    #{ex.message}") end -- cgit v1.2.3 From 8bd2e60abc42f51e6cdd246e883ab953cabd78ae Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Mon, 22 May 2023 09:19:32 -0400 Subject: Use string interpolation instead of concatenation Co-authored-by: Samantaz Fox --- src/invidious/routes/channels.cr | 2 +- src/invidious/routes/search.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 740f3096..16621994 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -278,7 +278,7 @@ module Invidious::Routes::Channels return error_template(500, ex) end - env.set "search", "channel:" + ucid + " " + env.set "search", "channel:#{ucid} " return {locale, user, subscriptions, continuation, ucid, channel} end end diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 7f17124e..6c3088de 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -66,7 +66,7 @@ module Invidious::Routes::Search redirect_url = Invidious::Frontend::Misc.redirect_url(env) if query.type == Invidious::Search::Query::Type::Channel - env.set "search", "channel:" + query.channel + " " + query.text + env.set "search", "channel:#{query.channel} #{query.text}" else env.set "search", query.text end -- cgit v1.2.3 From 6440ae0b5c15355dd87959412ea609396a198215 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 9 May 2023 23:37:49 +0200 Subject: Community: Fix position of the "creator heart" (broken by #3783) --- assets/css/default.css | 2 ++ src/invidious/comments.cr | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 23649f8f..431a0427 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -46,6 +46,7 @@ body a.channel-owner { } .creator-heart { + display: inline-block; position: relative; width: 16px; height: 16px; @@ -66,6 +67,7 @@ body a.channel-owner { } .creator-heart-small-container { + display: block; position: relative; width: 13px; height: 13px; diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 01556099..466c9fe5 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -409,7 +409,6 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) html << <<-END_HTML #{number_with_separator(child["likeCount"])} -

    END_HTML if child["creatorHeart"]? @@ -420,13 +419,14 @@ def template_youtube_comments(comments, locale, thin_mode, is_replies = false) end html << <<-END_HTML +   -
    + -
    -
    -
    -
    + + + +
    END_HTML end -- cgit v1.2.3 From c7876d564f09995244186f57d61cedfeb63038b6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 19:50:35 +0200 Subject: Comments: add 'require' statement for a dedicated folder --- src/invidious.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index d4f8e0fb..b5abd5c7 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -43,6 +43,7 @@ require "./invidious/videos/*" require "./invidious/jsonify/**" require "./invidious/*" +require "./invidious/comments/*" require "./invidious/channels/*" require "./invidious/user/*" require "./invidious/search/*" -- cgit v1.2.3 From 8dd18248692726e8db05138c4ce2b01f39ad62f6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 19:51:49 +0200 Subject: Comments: Move reddit type definitions to their own file --- src/invidious/comments.cr | 58 ---------------------------------- src/invidious/comments/reddit_types.cr | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 58 deletions(-) create mode 100644 src/invidious/comments/reddit_types.cr (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 466c9fe5..00e8d399 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -1,61 +1,3 @@ -class RedditThing - include JSON::Serializable - - property kind : String - property data : RedditComment | RedditLink | RedditMore | RedditListing -end - -class RedditComment - include JSON::Serializable - - property author : String - property body_html : String - property replies : RedditThing | String - property score : Int32 - property depth : Int32 - property permalink : String - - @[JSON::Field(converter: RedditComment::TimeConverter)] - property created_utc : Time - - module TimeConverter - def self.from_json(value : JSON::PullParser) : Time - Time.unix(value.read_float.to_i) - end - - def self.to_json(value : Time, json : JSON::Builder) - json.number(value.to_unix) - end - end -end - -struct RedditLink - include JSON::Serializable - - property author : String - property score : Int32 - property subreddit : String - property num_comments : Int32 - property id : String - property permalink : String - property title : String -end - -struct RedditMore - include JSON::Serializable - - property children : Array(String) - property count : Int32 - property depth : Int32 -end - -class RedditListing - include JSON::Serializable - - property children : Array(RedditThing) - property modhash : String -end - def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_by = "top") case cursor when nil, "" diff --git a/src/invidious/comments/reddit_types.cr b/src/invidious/comments/reddit_types.cr new file mode 100644 index 00000000..796a1183 --- /dev/null +++ b/src/invidious/comments/reddit_types.cr @@ -0,0 +1,57 @@ +class RedditThing + include JSON::Serializable + + property kind : String + property data : RedditComment | RedditLink | RedditMore | RedditListing +end + +class RedditComment + include JSON::Serializable + + property author : String + property body_html : String + property replies : RedditThing | String + property score : Int32 + property depth : Int32 + property permalink : String + + @[JSON::Field(converter: RedditComment::TimeConverter)] + property created_utc : Time + + module TimeConverter + def self.from_json(value : JSON::PullParser) : Time + Time.unix(value.read_float.to_i) + end + + def self.to_json(value : Time, json : JSON::Builder) + json.number(value.to_unix) + end + end +end + +struct RedditLink + include JSON::Serializable + + property author : String + property score : Int32 + property subreddit : String + property num_comments : Int32 + property id : String + property permalink : String + property title : String +end + +struct RedditMore + include JSON::Serializable + + property children : Array(String) + property count : Int32 + property depth : Int32 +end + +class RedditListing + include JSON::Serializable + + property children : Array(RedditThing) + property modhash : String +end -- cgit v1.2.3 From 1b25737b013d0589f396fa938ba2747e9a76af93 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 19:56:30 +0200 Subject: Comments: Move 'fetch_youtube' function to own file + module --- src/invidious/comments.cr | 203 --------------------------------- src/invidious/comments/youtube.cr | 206 ++++++++++++++++++++++++++++++++++ src/invidious/routes/api/v1/videos.cr | 2 +- src/invidious/routes/watch.cr | 6 +- 4 files changed, 210 insertions(+), 207 deletions(-) create mode 100644 src/invidious/comments/youtube.cr (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 00e8d399..07579cf3 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -1,206 +1,3 @@ -def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_by = "top") - case cursor - when nil, "" - ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by) - when .starts_with? "ADSJ" - ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by) - else - ctoken = cursor - end - - client_config = YoutubeAPI::ClientConfig.new(region: region) - response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) - contents = nil - - if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? - header = nil - on_response_received_endpoints.as_a.each do |item| - if item["reloadContinuationItemsCommand"]? - case item["reloadContinuationItemsCommand"]["slot"] - when "RELOAD_CONTINUATION_SLOT_HEADER" - header = item["reloadContinuationItemsCommand"]["continuationItems"][0] - when "RELOAD_CONTINUATION_SLOT_BODY" - # continuationItems is nil when video has no comments - contents = item["reloadContinuationItemsCommand"]["continuationItems"]? - end - elsif item["appendContinuationItemsAction"]? - contents = item["appendContinuationItemsAction"]["continuationItems"] - end - end - elsif response["continuationContents"]? - response = response["continuationContents"] - if response["commentRepliesContinuation"]? - body = response["commentRepliesContinuation"] - else - body = response["itemSectionContinuation"] - end - contents = body["contents"]? - header = body["header"]? - else - raise NotFoundException.new("Comments not found.") - end - - if !contents - if format == "json" - return {"comments" => [] of String}.to_json - else - return {"contentHtml" => "", "commentCount" => 0}.to_json - end - end - - continuation_item_renderer = nil - contents.as_a.reject! do |item| - if item["continuationItemRenderer"]? - continuation_item_renderer = item["continuationItemRenderer"] - true - end - end - - response = JSON.build do |json| - json.object do - if header - count_text = header["commentsHeaderRenderer"]["countText"] - comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?) - .try &.as_s.gsub(/\D/, "").to_i? || 0 - json.field "commentCount", comment_count - end - - json.field "videoId", id - - json.field "comments" do - json.array do - contents.as_a.each do |node| - json.object do - if node["commentThreadRenderer"]? - node = node["commentThreadRenderer"] - end - - if node["replies"]? - node_replies = node["replies"]["commentRepliesRenderer"] - end - - if node["comment"]? - node_comment = node["comment"]["commentRenderer"] - else - node_comment = node["commentRenderer"] - end - - content_html = node_comment["contentText"]?.try { |t| parse_content(t, id) } || "" - author = node_comment["authorText"]?.try &.["simpleText"]? || "" - - json.field "verified", (node_comment["authorCommentBadge"]? != nil) - - json.field "author", author - json.field "authorThumbnails" do - json.array do - node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail| - json.object do - json.field "url", thumbnail["url"] - json.field "width", thumbnail["width"] - json.field "height", thumbnail["height"] - end - end - end - end - - if node_comment["authorEndpoint"]? - json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"] - json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] - else - json.field "authorId", "" - json.field "authorUrl", "" - end - - published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s - published = decode_date(published_text.rchop(" (edited)")) - - if published_text.includes?(" (edited)") - json.field "isEdited", true - else - json.field "isEdited", false - end - - json.field "content", html_to_content(content_html) - json.field "contentHtml", content_html - - json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) - json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil) - if node_comment["sponsorCommentBadge"]? - # Sponsor icon thumbnails always have one object and there's only ever the url property in it - json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s - end - json.field "published", published.to_unix - json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) - - comment_action_buttons_renderer = node_comment["actionButtons"]["commentActionButtonsRenderer"] - - json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i - json.field "commentId", node_comment["commentId"] - json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] - - if comment_action_buttons_renderer["creatorHeart"]? - hearth_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"] - json.field "creatorHeart" do - json.object do - json.field "creatorThumbnail", hearth_data["thumbnails"][-1]["url"] - json.field "creatorName", hearth_data["accessibility"]["accessibilityData"]["label"] - end - end - end - - if node_replies && !response["commentRepliesContinuation"]? - if node_replies["continuations"]? - continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s - elsif node_replies["contents"]? - continuation = node_replies["contents"]?.try &.as_a[0]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s - end - continuation ||= "" - - json.field "replies" do - json.object do - json.field "replyCount", node_comment["replyCount"]? || 1 - json.field "continuation", continuation - end - end - end - end - end - end - end - - if continuation_item_renderer - if continuation_item_renderer["continuationEndpoint"]? - continuation_endpoint = continuation_item_renderer["continuationEndpoint"] - elsif continuation_item_renderer["button"]? - continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"] - end - if continuation_endpoint - json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s - end - end - end - end - - if format == "html" - response = JSON.parse(response) - content_html = template_youtube_comments(response, locale, thin_mode) - - response = JSON.build do |json| - json.object do - json.field "contentHtml", content_html - - if response["commentCount"]? - json.field "commentCount", response["commentCount"] - else - json.field "commentCount", 0 - end - end - end - end - - return response -end - def fetch_reddit_comments(id, sort_by = "confidence") client = make_client(REDDIT_URL) headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr new file mode 100644 index 00000000..7e0c8d24 --- /dev/null +++ b/src/invidious/comments/youtube.cr @@ -0,0 +1,206 @@ +module Invidious::Comments + extend self + + def fetch_youtube(id, cursor, format, locale, thin_mode, region, sort_by = "top") + case cursor + when nil, "" + ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by) + when .starts_with? "ADSJ" + ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by) + else + ctoken = cursor + end + + client_config = YoutubeAPI::ClientConfig.new(region: region) + response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) + contents = nil + + if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? + header = nil + on_response_received_endpoints.as_a.each do |item| + if item["reloadContinuationItemsCommand"]? + case item["reloadContinuationItemsCommand"]["slot"] + when "RELOAD_CONTINUATION_SLOT_HEADER" + header = item["reloadContinuationItemsCommand"]["continuationItems"][0] + when "RELOAD_CONTINUATION_SLOT_BODY" + # continuationItems is nil when video has no comments + contents = item["reloadContinuationItemsCommand"]["continuationItems"]? + end + elsif item["appendContinuationItemsAction"]? + contents = item["appendContinuationItemsAction"]["continuationItems"] + end + end + elsif response["continuationContents"]? + response = response["continuationContents"] + if response["commentRepliesContinuation"]? + body = response["commentRepliesContinuation"] + else + body = response["itemSectionContinuation"] + end + contents = body["contents"]? + header = body["header"]? + else + raise NotFoundException.new("Comments not found.") + end + + if !contents + if format == "json" + return {"comments" => [] of String}.to_json + else + return {"contentHtml" => "", "commentCount" => 0}.to_json + end + end + + continuation_item_renderer = nil + contents.as_a.reject! do |item| + if item["continuationItemRenderer"]? + continuation_item_renderer = item["continuationItemRenderer"] + true + end + end + + response = JSON.build do |json| + json.object do + if header + count_text = header["commentsHeaderRenderer"]["countText"] + comment_count = (count_text["simpleText"]? || count_text["runs"]?.try &.[0]?.try &.["text"]?) + .try &.as_s.gsub(/\D/, "").to_i? || 0 + json.field "commentCount", comment_count + end + + json.field "videoId", id + + json.field "comments" do + json.array do + contents.as_a.each do |node| + json.object do + if node["commentThreadRenderer"]? + node = node["commentThreadRenderer"] + end + + if node["replies"]? + node_replies = node["replies"]["commentRepliesRenderer"] + end + + if node["comment"]? + node_comment = node["comment"]["commentRenderer"] + else + node_comment = node["commentRenderer"] + end + + content_html = node_comment["contentText"]?.try { |t| parse_content(t, id) } || "" + author = node_comment["authorText"]?.try &.["simpleText"]? || "" + + json.field "verified", (node_comment["authorCommentBadge"]? != nil) + + json.field "author", author + json.field "authorThumbnails" do + json.array do + node_comment["authorThumbnail"]["thumbnails"].as_a.each do |thumbnail| + json.object do + json.field "url", thumbnail["url"] + json.field "width", thumbnail["width"] + json.field "height", thumbnail["height"] + end + end + end + end + + if node_comment["authorEndpoint"]? + json.field "authorId", node_comment["authorEndpoint"]["browseEndpoint"]["browseId"] + json.field "authorUrl", node_comment["authorEndpoint"]["browseEndpoint"]["canonicalBaseUrl"] + else + json.field "authorId", "" + json.field "authorUrl", "" + end + + published_text = node_comment["publishedTimeText"]["runs"][0]["text"].as_s + published = decode_date(published_text.rchop(" (edited)")) + + if published_text.includes?(" (edited)") + json.field "isEdited", true + else + json.field "isEdited", false + end + + json.field "content", html_to_content(content_html) + json.field "contentHtml", content_html + + json.field "isPinned", (node_comment["pinnedCommentBadge"]? != nil) + json.field "isSponsor", (node_comment["sponsorCommentBadge"]? != nil) + if node_comment["sponsorCommentBadge"]? + # Sponsor icon thumbnails always have one object and there's only ever the url property in it + json.field "sponsorIconUrl", node_comment.dig("sponsorCommentBadge", "sponsorCommentBadgeRenderer", "customBadge", "thumbnails", 0, "url").to_s + end + json.field "published", published.to_unix + json.field "publishedText", translate(locale, "`x` ago", recode_date(published, locale)) + + comment_action_buttons_renderer = node_comment["actionButtons"]["commentActionButtonsRenderer"] + + json.field "likeCount", comment_action_buttons_renderer["likeButton"]["toggleButtonRenderer"]["accessibilityData"]["accessibilityData"]["label"].as_s.scan(/\d/).map(&.[0]).join.to_i + json.field "commentId", node_comment["commentId"] + json.field "authorIsChannelOwner", node_comment["authorIsChannelOwner"] + + if comment_action_buttons_renderer["creatorHeart"]? + hearth_data = comment_action_buttons_renderer["creatorHeart"]["creatorHeartRenderer"]["creatorThumbnail"] + json.field "creatorHeart" do + json.object do + json.field "creatorThumbnail", hearth_data["thumbnails"][-1]["url"] + json.field "creatorName", hearth_data["accessibility"]["accessibilityData"]["label"] + end + end + end + + if node_replies && !response["commentRepliesContinuation"]? + if node_replies["continuations"]? + continuation = node_replies["continuations"]?.try &.as_a[0]["nextContinuationData"]["continuation"].as_s + elsif node_replies["contents"]? + continuation = node_replies["contents"]?.try &.as_a[0]["continuationItemRenderer"]["continuationEndpoint"]["continuationCommand"]["token"].as_s + end + continuation ||= "" + + json.field "replies" do + json.object do + json.field "replyCount", node_comment["replyCount"]? || 1 + json.field "continuation", continuation + end + end + end + end + end + end + end + + if continuation_item_renderer + if continuation_item_renderer["continuationEndpoint"]? + continuation_endpoint = continuation_item_renderer["continuationEndpoint"] + elsif continuation_item_renderer["button"]? + continuation_endpoint = continuation_item_renderer["button"]["buttonRenderer"]["command"] + end + if continuation_endpoint + json.field "continuation", continuation_endpoint["continuationCommand"]["token"].as_s + end + end + end + end + + if format == "html" + response = JSON.parse(response) + content_html = template_youtube_comments(response, locale, thin_mode) + + response = JSON.build do |json| + json.object do + json.field "contentHtml", content_html + + if response["commentCount"]? + json.field "commentCount", response["commentCount"] + else + json.field "commentCount", 0 + end + end + end + end + + return response + end +end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index f312211e..ce3e96d2 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -333,7 +333,7 @@ module Invidious::Routes::API::V1::Videos sort_by ||= "top" begin - comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) + comments = Comments.fetch_youtube(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) rescue ex : NotFoundException return error_json(404, ex) rescue ex diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 813cb0f4..861b25c2 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -95,7 +95,7 @@ module Invidious::Routes::Watch if source == "youtube" begin - comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] rescue ex if preferences.comments[1] == "reddit" comments, reddit_thread = fetch_reddit_comments(id) @@ -114,12 +114,12 @@ module Invidious::Routes::Watch comment_html = replace_links(comment_html) rescue ex if preferences.comments[1] == "youtube" - comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] end end end else - comment_html = JSON.parse(fetch_youtube_comments(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] + comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] end comment_html ||= "" -- cgit v1.2.3 From 634e913da9381f5212a1017e2f4a37e7d7075204 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 20:02:42 +0200 Subject: Comments: Move 'fetch_reddit' function to own file + module --- src/invidious/comments.cr | 38 -------------------------------- src/invidious/comments/reddit.cr | 41 +++++++++++++++++++++++++++++++++++ src/invidious/routes/api/v1/videos.cr | 2 +- src/invidious/routes/watch.cr | 4 ++-- 4 files changed, 44 insertions(+), 41 deletions(-) create mode 100644 src/invidious/comments/reddit.cr (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 07579cf3..07b92786 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -1,41 +1,3 @@ -def fetch_reddit_comments(id, sort_by = "confidence") - client = make_client(REDDIT_URL) - headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} - - # TODO: Use something like #479 for a static list of instances to use here - query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"}) - search_results = client.get("/search.json?#{query}", headers) - - if search_results.status_code == 200 - search_results = RedditThing.from_json(search_results.body) - - # For videos that have more than one thread, choose the one with the highest score - threads = search_results.data.as(RedditListing).children - thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink)) - result = thread.try do |t| - body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body - Array(RedditThing).from_json(body) - end - result ||= [] of RedditThing - elsif search_results.status_code == 302 - # Previously, if there was only one result then the API would redirect to that result. - # Now, it appears it will still return a listing so this section is likely unnecessary. - - result = client.get(search_results.headers["Location"], headers).body - result = Array(RedditThing).from_json(result) - - thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) - else - raise NotFoundException.new("Comments not found.") - end - - client.close - - comments = result[1]?.try(&.data.as(RedditListing).children) - comments ||= [] of RedditThing - return comments, thread -end - def template_youtube_comments(comments, locale, thin_mode, is_replies = false) String.build do |html| root = comments["comments"].as_a diff --git a/src/invidious/comments/reddit.cr b/src/invidious/comments/reddit.cr new file mode 100644 index 00000000..ba9c19f1 --- /dev/null +++ b/src/invidious/comments/reddit.cr @@ -0,0 +1,41 @@ +module Invidious::Comments + extend self + + def fetch_reddit(id, sort_by = "confidence") + client = make_client(REDDIT_URL) + headers = HTTP::Headers{"User-Agent" => "web:invidious:v#{CURRENT_VERSION} (by github.com/iv-org/invidious)"} + + # TODO: Use something like #479 for a static list of instances to use here + query = URI::Params.encode({q: "(url:3D#{id} OR url:#{id}) AND (site:invidio.us OR site:youtube.com OR site:youtu.be)"}) + search_results = client.get("/search.json?#{query}", headers) + + if search_results.status_code == 200 + search_results = RedditThing.from_json(search_results.body) + + # For videos that have more than one thread, choose the one with the highest score + threads = search_results.data.as(RedditListing).children + thread = threads.max_by?(&.data.as(RedditLink).score).try(&.data.as(RedditLink)) + result = thread.try do |t| + body = client.get("/r/#{t.subreddit}/comments/#{t.id}.json?limit=100&sort=#{sort_by}", headers).body + Array(RedditThing).from_json(body) + end + result ||= [] of RedditThing + elsif search_results.status_code == 302 + # Previously, if there was only one result then the API would redirect to that result. + # Now, it appears it will still return a listing so this section is likely unnecessary. + + result = client.get(search_results.headers["Location"], headers).body + result = Array(RedditThing).from_json(result) + + thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) + else + raise NotFoundException.new("Comments not found.") + end + + client.close + + comments = result[1]?.try(&.data.as(RedditListing).children) + comments ||= [] of RedditThing + return comments, thread + end +end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index ce3e96d2..cb1008ac 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -345,7 +345,7 @@ module Invidious::Routes::API::V1::Videos sort_by ||= "confidence" begin - comments, reddit_thread = fetch_reddit_comments(id, sort_by: sort_by) + comments, reddit_thread = Comments.fetch_reddit(id, sort_by: sort_by) rescue ex comments = nil reddit_thread = nil diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 861b25c2..b08e6fbe 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -98,7 +98,7 @@ module Invidious::Routes::Watch comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] rescue ex if preferences.comments[1] == "reddit" - comments, reddit_thread = fetch_reddit_comments(id) + comments, reddit_thread = Comments.fetch_reddit(id) comment_html = template_reddit_comments(comments, locale) comment_html = fill_links(comment_html, "https", "www.reddit.com") @@ -107,7 +107,7 @@ module Invidious::Routes::Watch end elsif source == "reddit" begin - comments, reddit_thread = fetch_reddit_comments(id) + comments, reddit_thread = Comments.fetch_reddit(id) comment_html = template_reddit_comments(comments, locale) comment_html = fill_links(comment_html, "https", "www.reddit.com") -- cgit v1.2.3 From e10f6b6626bfe462861980184b09b7350499c889 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 20:07:13 +0200 Subject: Comments: Move 'template_youtube' function to own file + module --- src/invidious/channels/community.cr | 2 +- src/invidious/comments.cr | 157 ---------------------------- src/invidious/comments/youtube.cr | 2 +- src/invidious/frontend/comments_youtube.cr | 160 +++++++++++++++++++++++++++++ src/invidious/views/community.ecr | 2 +- 5 files changed, 163 insertions(+), 160 deletions(-) create mode 100644 src/invidious/frontend/comments_youtube.cr (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 2c7b9fec..aac4bc8a 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -250,7 +250,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) if format == "html" response = JSON.parse(response) - content_html = template_youtube_comments(response, locale, thin_mode) + content_html = IV::Frontend::Comments.template_youtube(response, locale, thin_mode) response = JSON.build do |json| json.object do diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 07b92786..8943b1da 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -1,160 +1,3 @@ -def template_youtube_comments(comments, locale, thin_mode, is_replies = false) - String.build do |html| - root = comments["comments"].as_a - root.each do |child| - if child["replies"]? - replies_count_text = translate_count(locale, - "comments_view_x_replies", - child["replies"]["replyCount"].as_i64 || 0, - NumberFormatting::Separator - ) - - replies_html = <<-END_HTML -
    -
    - -
    - END_HTML - end - - if !thin_mode - author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}" - else - author_thumbnail = "" - end - - author_name = HTML.escape(child["author"].as_s) - sponsor_icon = "" - if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool - author_name += " " - elsif child["verified"]?.try &.as_bool - author_name += " " - end - - if child["isSponsor"]?.try &.as_bool - sponsor_icon = String.build do |str| - str << %() - end - end - html << <<-END_HTML -
    -
    - -
    -
    -

    - - #{author_name} - - #{sponsor_icon} -

    #{child["contentHtml"]}

    - END_HTML - - if child["attachment"]? - attachment = child["attachment"] - - case attachment["type"] - when "image" - attachment = attachment["imageThumbnails"][1] - - html << <<-END_HTML -
    -
    - -
    -
    - END_HTML - when "video" - if attachment["error"]? - html << <<-END_HTML -
    -

    #{attachment["error"]}

    -
    - END_HTML - else - html << <<-END_HTML -
    - -
    - END_HTML - end - else nil # Ignore - end - end - - html << <<-END_HTML -

    - #{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""} - | - END_HTML - - if comments["videoId"]? - html << <<-END_HTML - [YT] - | - END_HTML - elsif comments["authorId"]? - html << <<-END_HTML - [YT] - | - END_HTML - end - - html << <<-END_HTML - #{number_with_separator(child["likeCount"])} - END_HTML - - if child["creatorHeart"]? - if !thin_mode - creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}" - else - creator_thumbnail = "" - end - - html << <<-END_HTML -   - - - - - - - - - END_HTML - end - - html << <<-END_HTML -

    - #{replies_html} -
    -
    - END_HTML - end - - if comments["continuation"]? - html << <<-END_HTML - - END_HTML - end - end -end - def template_reddit_comments(root, locale) String.build do |html| root.each do |child| diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr index 7e0c8d24..c262876e 100644 --- a/src/invidious/comments/youtube.cr +++ b/src/invidious/comments/youtube.cr @@ -186,7 +186,7 @@ module Invidious::Comments if format == "html" response = JSON.parse(response) - content_html = template_youtube_comments(response, locale, thin_mode) + content_html = Frontend::Comments.template_youtube(response, locale, thin_mode) response = JSON.build do |json| json.object do diff --git a/src/invidious/frontend/comments_youtube.cr b/src/invidious/frontend/comments_youtube.cr new file mode 100644 index 00000000..41f43f04 --- /dev/null +++ b/src/invidious/frontend/comments_youtube.cr @@ -0,0 +1,160 @@ +module Invidious::Frontend::Comments + extend self + + def template_youtube(comments, locale, thin_mode, is_replies = false) + String.build do |html| + root = comments["comments"].as_a + root.each do |child| + if child["replies"]? + replies_count_text = translate_count(locale, + "comments_view_x_replies", + child["replies"]["replyCount"].as_i64 || 0, + NumberFormatting::Separator + ) + + replies_html = <<-END_HTML +
    +
    + +
    + END_HTML + end + + if !thin_mode + author_thumbnail = "/ggpht#{URI.parse(child["authorThumbnails"][-1]["url"].as_s).request_target}" + else + author_thumbnail = "" + end + + author_name = HTML.escape(child["author"].as_s) + sponsor_icon = "" + if child["verified"]?.try &.as_bool && child["authorIsChannelOwner"]?.try &.as_bool + author_name += " " + elsif child["verified"]?.try &.as_bool + author_name += " " + end + + if child["isSponsor"]?.try &.as_bool + sponsor_icon = String.build do |str| + str << %() + end + end + html << <<-END_HTML +
    +
    + +
    +
    +

    + + #{author_name} + + #{sponsor_icon} +

    #{child["contentHtml"]}

    + END_HTML + + if child["attachment"]? + attachment = child["attachment"] + + case attachment["type"] + when "image" + attachment = attachment["imageThumbnails"][1] + + html << <<-END_HTML +
    +
    + +
    +
    + END_HTML + when "video" + if attachment["error"]? + html << <<-END_HTML +
    +

    #{attachment["error"]}

    +
    + END_HTML + else + html << <<-END_HTML +
    + +
    + END_HTML + end + else nil # Ignore + end + end + + html << <<-END_HTML +

    + #{translate(locale, "`x` ago", recode_date(Time.unix(child["published"].as_i64), locale))} #{child["isEdited"] == true ? translate(locale, "(edited)") : ""} + | + END_HTML + + if comments["videoId"]? + html << <<-END_HTML + [YT] + | + END_HTML + elsif comments["authorId"]? + html << <<-END_HTML + [YT] + | + END_HTML + end + + html << <<-END_HTML + #{number_with_separator(child["likeCount"])} + END_HTML + + if child["creatorHeart"]? + if !thin_mode + creator_thumbnail = "/ggpht#{URI.parse(child["creatorHeart"]["creatorThumbnail"].as_s).request_target}" + else + creator_thumbnail = "" + end + + html << <<-END_HTML +   + + + + + + + + + END_HTML + end + + html << <<-END_HTML +

    + #{replies_html} +
    +
    + END_HTML + end + + if comments["continuation"]? + html << <<-END_HTML + + END_HTML + end + end + end +end diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 9e11d562..24efc34e 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -27,7 +27,7 @@
    <% else %>
    - <%= template_youtube_comments(items.not_nil!, locale, thin_mode) %> + <%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %>
    <% end %> -- cgit v1.2.3 From de78848039c2e5e8dea25b6013f3e24797a0b1ce Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 20:12:02 +0200 Subject: Comments: Move 'template_reddit' function to own file + module --- src/invidious/comments.cr | 47 ----------------------------- src/invidious/frontend/comments_reddit.cr | 50 +++++++++++++++++++++++++++++++ src/invidious/routes/api/v1/videos.cr | 2 +- src/invidious/routes/watch.cr | 4 +-- 4 files changed, 53 insertions(+), 50 deletions(-) create mode 100644 src/invidious/frontend/comments_reddit.cr (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 8943b1da..6a3aa4c2 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -1,50 +1,3 @@ -def template_reddit_comments(root, locale) - String.build do |html| - root.each do |child| - if child.data.is_a?(RedditComment) - child = child.data.as(RedditComment) - body_html = HTML.unescape(child.body_html) - - replies_html = "" - if child.replies.is_a?(RedditThing) - replies = child.replies.as(RedditThing) - replies_html = template_reddit_comments(replies.data.as(RedditListing).children, locale) - end - - if child.depth > 0 - html << <<-END_HTML -
    -
    -
    -
    - END_HTML - else - html << <<-END_HTML -
    -
    - END_HTML - end - - html << <<-END_HTML -

    - [ − ] - #{child.author} - #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)} - #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))} - #{translate(locale, "permalink")} -

    -
    - #{body_html} - #{replies_html} -
    -
    -
    - END_HTML - end - end - end -end - def replace_links(html) # Check if the document is empty # Prevents edge-case bug with Reddit comments, see issue #3115 diff --git a/src/invidious/frontend/comments_reddit.cr b/src/invidious/frontend/comments_reddit.cr new file mode 100644 index 00000000..b5647bae --- /dev/null +++ b/src/invidious/frontend/comments_reddit.cr @@ -0,0 +1,50 @@ +module Invidious::Frontend::Comments + extend self + + def template_reddit(root, locale) + String.build do |html| + root.each do |child| + if child.data.is_a?(RedditComment) + child = child.data.as(RedditComment) + body_html = HTML.unescape(child.body_html) + + replies_html = "" + if child.replies.is_a?(RedditThing) + replies = child.replies.as(RedditThing) + replies_html = self.template_reddit(replies.data.as(RedditListing).children, locale) + end + + if child.depth > 0 + html << <<-END_HTML +
    +
    +
    +
    + END_HTML + else + html << <<-END_HTML +
    +
    + END_HTML + end + + html << <<-END_HTML +

    + [ − ] + #{child.author} + #{translate_count(locale, "comments_points_count", child.score, NumberFormatting::Separator)} + #{translate(locale, "`x` ago", recode_date(child.created_utc, locale))} + #{translate(locale, "permalink")} +

    +
    + #{body_html} + #{replies_html} +
    +
    +
    + END_HTML + end + end + end + end +end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index cb1008ac..6feaaef4 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -361,7 +361,7 @@ module Invidious::Routes::API::V1::Videos return reddit_thread.to_json else - content_html = template_reddit_comments(comments, locale) + content_html = Frontend::Comments.template_reddit(comments, locale) content_html = fill_links(content_html, "https", "www.reddit.com") content_html = replace_links(content_html) response = { diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index b08e6fbe..6b441a48 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -99,7 +99,7 @@ module Invidious::Routes::Watch rescue ex if preferences.comments[1] == "reddit" comments, reddit_thread = Comments.fetch_reddit(id) - comment_html = template_reddit_comments(comments, locale) + comment_html = Frontend::Comments.template_reddit(comments, locale) comment_html = fill_links(comment_html, "https", "www.reddit.com") comment_html = replace_links(comment_html) @@ -108,7 +108,7 @@ module Invidious::Routes::Watch elsif source == "reddit" begin comments, reddit_thread = Comments.fetch_reddit(id) - comment_html = template_reddit_comments(comments, locale) + comment_html = Frontend::Comments.template_reddit(comments, locale) comment_html = fill_links(comment_html, "https", "www.reddit.com") comment_html = replace_links(comment_html) -- cgit v1.2.3 From df8526545383f4def3605fb61551edbd851c18c7 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 20:20:27 +0200 Subject: Comments: Move link utility functions to own file + module --- src/invidious/comments.cr | 73 --------------------------------- src/invidious/comments/links_util.cr | 76 +++++++++++++++++++++++++++++++++++ src/invidious/routes/api/v1/videos.cr | 4 +- src/invidious/routes/watch.cr | 8 ++-- 4 files changed, 82 insertions(+), 79 deletions(-) create mode 100644 src/invidious/comments/links_util.cr (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 6a3aa4c2..3c7e2bb4 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -1,76 +1,3 @@ -def replace_links(html) - # Check if the document is empty - # Prevents edge-case bug with Reddit comments, see issue #3115 - if html.nil? || html.empty? - return html - end - - html = XML.parse_html(html) - - html.xpath_nodes(%q(//a)).each do |anchor| - url = URI.parse(anchor["href"]) - - if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be") - if url.host.try &.ends_with? "youtu.be" - url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}" - else - if url.path == "/redirect" - params = HTTP::Params.parse(url.query.not_nil!) - anchor["href"] = params["q"]? - else - anchor["href"] = url.request_target - end - end - elsif url.to_s == "#" - begin - length_seconds = decode_length_seconds(anchor.content) - rescue ex - length_seconds = decode_time(anchor.content) - end - - if length_seconds > 0 - anchor["href"] = "javascript:void(0)" - anchor["onclick"] = "player.currentTime(#{length_seconds})" - else - anchor["href"] = url.request_target - end - end - end - - html = html.xpath_node(%q(//body)).not_nil! - if node = html.xpath_node(%q(./p)) - html = node - end - - return html.to_xml(options: XML::SaveOptions::NO_DECL) -end - -def fill_links(html, scheme, host) - # Check if the document is empty - # Prevents edge-case bug with Reddit comments, see issue #3115 - if html.nil? || html.empty? - return html - end - - html = XML.parse_html(html) - - html.xpath_nodes("//a").each do |match| - url = URI.parse(match["href"]) - # Reddit links don't have host - if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#" - url.scheme = scheme - url.host = host - match["href"] = url - end - end - - if host == "www.youtube.com" - html = html.xpath_node(%q(//body/p)).not_nil! - end - - return html.to_xml(options: XML::SaveOptions::NO_DECL) -end - def text_to_parsed_content(text : String) : JSON::Any nodes = [] of JSON::Any # For each line convert line to array of nodes diff --git a/src/invidious/comments/links_util.cr b/src/invidious/comments/links_util.cr new file mode 100644 index 00000000..f89b86d3 --- /dev/null +++ b/src/invidious/comments/links_util.cr @@ -0,0 +1,76 @@ +module Invidious::Comments + extend self + + def replace_links(html) + # Check if the document is empty + # Prevents edge-case bug with Reddit comments, see issue #3115 + if html.nil? || html.empty? + return html + end + + html = XML.parse_html(html) + + html.xpath_nodes(%q(//a)).each do |anchor| + url = URI.parse(anchor["href"]) + + if url.host.nil? || url.host.not_nil!.ends_with?("youtube.com") || url.host.not_nil!.ends_with?("youtu.be") + if url.host.try &.ends_with? "youtu.be" + url = "/watch?v=#{url.path.lstrip('/')}#{url.query_params}" + else + if url.path == "/redirect" + params = HTTP::Params.parse(url.query.not_nil!) + anchor["href"] = params["q"]? + else + anchor["href"] = url.request_target + end + end + elsif url.to_s == "#" + begin + length_seconds = decode_length_seconds(anchor.content) + rescue ex + length_seconds = decode_time(anchor.content) + end + + if length_seconds > 0 + anchor["href"] = "javascript:void(0)" + anchor["onclick"] = "player.currentTime(#{length_seconds})" + else + anchor["href"] = url.request_target + end + end + end + + html = html.xpath_node(%q(//body)).not_nil! + if node = html.xpath_node(%q(./p)) + html = node + end + + return html.to_xml(options: XML::SaveOptions::NO_DECL) + end + + def fill_links(html, scheme, host) + # Check if the document is empty + # Prevents edge-case bug with Reddit comments, see issue #3115 + if html.nil? || html.empty? + return html + end + + html = XML.parse_html(html) + + html.xpath_nodes("//a").each do |match| + url = URI.parse(match["href"]) + # Reddit links don't have host + if !url.host && !match["href"].starts_with?("javascript") && !url.to_s.ends_with? "#" + url.scheme = scheme + url.host = host + match["href"] = url + end + end + + if host == "www.youtube.com" + html = html.xpath_node(%q(//body/p)).not_nil! + end + + return html.to_xml(options: XML::SaveOptions::NO_DECL) + end +end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 6feaaef4..af4fc806 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -362,8 +362,8 @@ module Invidious::Routes::API::V1::Videos return reddit_thread.to_json else content_html = Frontend::Comments.template_reddit(comments, locale) - content_html = fill_links(content_html, "https", "www.reddit.com") - content_html = replace_links(content_html) + content_html = Comments.fill_links(content_html, "https", "www.reddit.com") + content_html = Comments.replace_links(content_html) response = { "title" => reddit_thread.title, "permalink" => reddit_thread.permalink, diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 6b441a48..e5cf3716 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -101,8 +101,8 @@ module Invidious::Routes::Watch comments, reddit_thread = Comments.fetch_reddit(id) comment_html = Frontend::Comments.template_reddit(comments, locale) - comment_html = fill_links(comment_html, "https", "www.reddit.com") - comment_html = replace_links(comment_html) + comment_html = Comments.fill_links(comment_html, "https", "www.reddit.com") + comment_html = Comments.replace_links(comment_html) end end elsif source == "reddit" @@ -110,8 +110,8 @@ module Invidious::Routes::Watch comments, reddit_thread = Comments.fetch_reddit(id) comment_html = Frontend::Comments.template_reddit(comments, locale) - comment_html = fill_links(comment_html, "https", "www.reddit.com") - comment_html = replace_links(comment_html) + comment_html = Comments.fill_links(comment_html, "https", "www.reddit.com") + comment_html = Comments.replace_links(comment_html) rescue ex if preferences.comments[1] == "youtube" comment_html = JSON.parse(Comments.fetch_youtube(id, nil, "html", locale, preferences.thin_mode, region))["contentHtml"] -- cgit v1.2.3 From 4379a3d873540460859ec30845dfba66a33d0aea Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 20:23:47 +0200 Subject: Comments: Move ctoken functions to youtube.cr --- spec/invidious/helpers_spec.cr | 12 ---------- src/invidious/comments.cr | 44 ----------------------------------- src/invidious/comments/youtube.cr | 48 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 46 insertions(+), 58 deletions(-) (limited to 'src') diff --git a/spec/invidious/helpers_spec.cr b/spec/invidious/helpers_spec.cr index f81cd29a..142e1653 100644 --- a/spec/invidious/helpers_spec.cr +++ b/spec/invidious/helpers_spec.cr @@ -23,18 +23,6 @@ Spectator.describe "Helper" do end end - describe "#produce_comment_continuation" do - it "correctly produces a continuation token for comments" do - expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i2qvJeFtL0htmS5_K5Ctj3eGFVBMWL9Wd42o3kmUL6_mAzdLp85-liQZL0mYr_16BhaggUqX652Sv9JqV6VXinShSP-ZT6rL4NolPBaPXVtJsO5_rA_qE3GubAuLFw9uzIIXU2-HnpXbdgPLWTFavfX206hqWmmpHwUOrmxQV_OX6tYkM3ux3rPAKCDrT8eWL7MU3bLiNcnbgkW8o0h8KYLL_8BPa8LcHbTv8pAoNkjerlX1x7K4pqxaXPoyz89qNlnh6rRx6AXgAzzoHH1dmcyQ8CIBeOHg-m4i8ZxdX4dP88XWrIFg-jJGhpGP8JUMDgZgavxVx225hUEYZMyrLGler5em4FgbG62YWC51moLDLeYEA")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyjAMK9gJBRFNKX2kycXZKZUZ0TDBodG1TNV9LNUN0ajNlR0ZWQk1XTDlXZDQybzNrbVVMNl9tQXpkTHA4NS1saVFaTDBtWXJfMTZCaGFnZ1VxWDY1MlN2OUpxVjZWWGluU2hTUC1aVDZyTDROb2xQQmFQWFZ0SnNPNV9yQV9xRTNHdWJBdUxGdzl1eklJWFUyLUhucFhiZGdQTFdURmF2ZlgyMDZocVdtbXBId1VPcm14UVZfT1g2dFlrTTN1eDNyUEFLQ0RyVDhlV0w3TVUzYkxpTmNuYmdrVzhvMGg4S1lMTF84QlBhOExjSGJUdjhwQW9Oa2plcmxYMXg3SzRwcXhhWFBveXo4OXFObG5oNnJSeDZBWGdBenpvSEgxZG1jeVE4Q0lCZU9IZy1tNGk4WnhkWDRkUDg4WFdySUZnLWpKR2hwR1A4SlVNRGdaZ2F2eFZ4MjI1aFVFWVpNeXJMR2xlcjVlbTRGZ2JHNjJZV0M1MW1vTERMZVlFQSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") - - expect(produce_comment_continuation("_cE8xSu6swE", "ADSJ_i1yz21HI4xrtsYXVC-2_kfZ6kx1yjYQumXAAxqH3CAd7ZxKxfLdZS1__fqhCtOASRbbpSBGH_tH1J96Dxux-Qfjk-lUbupMqv08Q3aHzGu7p70VoUMHhI2-GoJpnbpmcOxkGzeIuenRS_ym2Y8fkDowhqLPFgsS0n4djnZ2UmC17F3Ch3N1S1UYf1ZVOc991qOC1iW9kJDzyvRQTWCPsJUPneSaAKW-Rr97pdesOkR4i8cNvHZRnQKe2HEfsvlJOb2C3lF1dJBfJeNfnQYeh5hv6_fZN7bt3-JL1Xk3Qc9NXNxmmbDpwAC_yFR8dthFfUJdyIO9Nu1D79MLYeR-H5HxqUJokkJiGIz4lTE_CXXbhAI")).to eq("EkMSC19jRTh4U3U2c3dFyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyiQMK8wJBRFNKX2kxeXoyMUhJNHhydHNZWFZDLTJfa2ZaNmt4MXlqWVF1bVhBQXhxSDNDQWQ3WnhLeGZMZFpTMV9fZnFoQ3RPQVNSYmJwU0JHSF90SDFKOTZEeHV4LVFmamstbFVidXBNcXYwOFEzYUh6R3U3cDcwVm9VTUhoSTItR29KcG5icG1jT3hrR3plSXVlblJTX3ltMlk4ZmtEb3docUxQRmdzUzBuNGRqbloyVW1DMTdGM0NoM04xUzFVWWYxWlZPYzk5MXFPQzFpVzlrSkR6eXZSUVRXQ1BzSlVQbmVTYUFLVy1Scjk3cGRlc09rUjRpOGNOdkhaUm5RS2UySEVmc3ZsSk9iMkMzbEYxZEpCZkplTmZuUVllaDVodjZfZlpON2J0My1KTDFYazNRYzlOWE54bW1iRHB3QUNfeUZSOGR0aEZmVUpkeUlPOU51MUQ3OU1MWWVSLUg1SHhxVUpva2tKaUdJejRsVEVfQ1hYYmhBSSIPIgtfY0U4eFN1NnN3RTAAKBQ%3D") - - expect(produce_comment_continuation("29-q7YnyUmY", "")).to eq("EkMSCzI5LXE3WW55VW1ZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iCzI5LXE3WW55VW1ZMAAoFA%3D%3D") - - expect(produce_comment_continuation("CvFH_6DNRCY", "")).to eq("EkMSC0N2RkhfNkROUkNZyAEA4AEBogINKP___________wFAAMICHQgEGhdodHRwczovL3d3dy55b3V0dWJlLmNvbSIAGAYyFQoAIg8iC0N2RkhfNkROUkNZMAAoFA%3D%3D") - end - end - describe "#produce_channel_community_continuation" do it "correctly produces a continuation token for a channel community" do expect(produce_channel_community_continuation("UCCj956IF62FbT7Gouszaj9w", "Egljb21tdW5pdHm4")).to eq("4qmFsgIsEhhVQ0NqOTU2SUY2MkZiVDdHb3VzemFqOXcaEEVnbGpiMjF0ZFc1cGRIbTQ%3D") diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 3c7e2bb4..c8cdc2df 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -87,47 +87,3 @@ def content_to_comment_html(content, video_id : String? = "") return html_array.join("").delete('\ufeff') end - -def produce_comment_continuation(video_id, cursor = "", sort_by = "top") - object = { - "2:embedded" => { - "2:string" => video_id, - "25:varint" => 0_i64, - "28:varint" => 1_i64, - "36:embedded" => { - "5:varint" => -1_i64, - "8:varint" => 0_i64, - }, - "40:embedded" => { - "1:varint" => 4_i64, - "3:string" => "https://www.youtube.com", - "4:string" => "", - }, - }, - "3:varint" => 6_i64, - "6:embedded" => { - "1:string" => cursor, - "4:embedded" => { - "4:string" => video_id, - "6:varint" => 0_i64, - }, - "5:varint" => 20_i64, - }, - } - - case sort_by - when "top" - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 - when "new", "newest" - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64 - else # top - object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 - end - - continuation = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr index c262876e..1ba1b534 100644 --- a/src/invidious/comments/youtube.cr +++ b/src/invidious/comments/youtube.cr @@ -4,9 +4,9 @@ module Invidious::Comments def fetch_youtube(id, cursor, format, locale, thin_mode, region, sort_by = "top") case cursor when nil, "" - ctoken = produce_comment_continuation(id, cursor: "", sort_by: sort_by) + ctoken = Comments.produce_continuation(id, cursor: "", sort_by: sort_by) when .starts_with? "ADSJ" - ctoken = produce_comment_continuation(id, cursor: cursor, sort_by: sort_by) + ctoken = Comments.produce_continuation(id, cursor: cursor, sort_by: sort_by) else ctoken = cursor end @@ -203,4 +203,48 @@ module Invidious::Comments return response end + + def produce_continuation(video_id, cursor = "", sort_by = "top") + object = { + "2:embedded" => { + "2:string" => video_id, + "25:varint" => 0_i64, + "28:varint" => 1_i64, + "36:embedded" => { + "5:varint" => -1_i64, + "8:varint" => 0_i64, + }, + "40:embedded" => { + "1:varint" => 4_i64, + "3:string" => "https://www.youtube.com", + "4:string" => "", + }, + }, + "3:varint" => 6_i64, + "6:embedded" => { + "1:string" => cursor, + "4:embedded" => { + "4:string" => video_id, + "6:varint" => 0_i64, + }, + "5:varint" => 20_i64, + }, + } + + case sort_by + when "top" + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 + when "new", "newest" + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 1_i64 + else # top + object["6:embedded"].as(Hash)["4:embedded"].as(Hash)["6:varint"] = 0_i64 + end + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation + end end -- cgit v1.2.3 From f0c8477905e6aae5c3979a64dab964dc4b353fe0 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 May 2023 20:27:02 +0200 Subject: Comments: Move content-related functions to their own file --- src/invidious/comments.cr | 89 --------------------------------------- src/invidious/comments/content.cr | 89 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 89 deletions(-) delete mode 100644 src/invidious/comments.cr create mode 100644 src/invidious/comments/content.cr (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr deleted file mode 100644 index c8cdc2df..00000000 --- a/src/invidious/comments.cr +++ /dev/null @@ -1,89 +0,0 @@ -def text_to_parsed_content(text : String) : JSON::Any - nodes = [] of JSON::Any - # For each line convert line to array of nodes - text.split('\n').each do |line| - # In first case line is just a simple node before - # check patterns inside line - # { 'text': line } - currentNodes = [] of JSON::Any - initialNode = {"text" => line} - currentNodes << (JSON.parse(initialNode.to_json)) - - # For each match with url pattern, get last node and preserve - # last node before create new node with url information - # { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } } - line.scan(/https?:\/\/[^ ]*/).each do |urlMatch| - # Retrieve last node and update node without match - lastNode = currentNodes[currentNodes.size - 1].as_h - splittedLastNode = lastNode["text"].as_s.split(urlMatch[0]) - lastNode["text"] = JSON.parse(splittedLastNode[0].to_json) - currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) - # Create new node with match and navigation infos - currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}} - currentNodes << (JSON.parse(currentNode.to_json)) - # If text remain after match create new simple node with text after match - afterNode = {"text" => splittedLastNode.size > 1 ? splittedLastNode[1] : ""} - currentNodes << (JSON.parse(afterNode.to_json)) - end - - # After processing of matches inside line - # Add \n at end of last node for preserve carriage return - lastNode = currentNodes[currentNodes.size - 1].as_h - lastNode["text"] = JSON.parse("#{currentNodes[currentNodes.size - 1]["text"]}\n".to_json) - currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) - - # Finally add final nodes to nodes returned - currentNodes.each do |node| - nodes << (node) - end - end - return JSON.parse({"runs" => nodes}.to_json) -end - -def parse_content(content : JSON::Any, video_id : String? = "") : String - content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || - content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r, video_id).try &.to_s.gsub("\n", "
    ") } || "" -end - -def content_to_comment_html(content, video_id : String? = "") - html_array = content.map do |run| - # Sometimes, there is an empty element. - # See: https://github.com/iv-org/invidious/issues/3096 - next if run.as_h.empty? - - text = HTML.escape(run["text"].as_s) - - if navigationEndpoint = run.dig?("navigationEndpoint") - text = parse_link_endpoint(navigationEndpoint, text, video_id) - end - - text = "#{text}" if run["bold"]? - text = "#{text}" if run["strikethrough"]? - text = "#{text}" if run["italics"]? - - # check for custom emojis - if run["emoji"]? - if run["emoji"]["isCustomEmoji"]?.try &.as_bool - if emojiImage = run.dig?("emoji", "image") - emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text - emojiThumb = emojiImage["thumbnails"][0] - text = String.build do |str| - str << %() << emojiAlt << ) - end - else - # Hide deleted channel emoji - text = "" - end - end - end - - text - end - - return html_array.join("").delete('\ufeff') -end diff --git a/src/invidious/comments/content.cr b/src/invidious/comments/content.cr new file mode 100644 index 00000000..c8cdc2df --- /dev/null +++ b/src/invidious/comments/content.cr @@ -0,0 +1,89 @@ +def text_to_parsed_content(text : String) : JSON::Any + nodes = [] of JSON::Any + # For each line convert line to array of nodes + text.split('\n').each do |line| + # In first case line is just a simple node before + # check patterns inside line + # { 'text': line } + currentNodes = [] of JSON::Any + initialNode = {"text" => line} + currentNodes << (JSON.parse(initialNode.to_json)) + + # For each match with url pattern, get last node and preserve + # last node before create new node with url information + # { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } } + line.scan(/https?:\/\/[^ ]*/).each do |urlMatch| + # Retrieve last node and update node without match + lastNode = currentNodes[currentNodes.size - 1].as_h + splittedLastNode = lastNode["text"].as_s.split(urlMatch[0]) + lastNode["text"] = JSON.parse(splittedLastNode[0].to_json) + currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) + # Create new node with match and navigation infos + currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}} + currentNodes << (JSON.parse(currentNode.to_json)) + # If text remain after match create new simple node with text after match + afterNode = {"text" => splittedLastNode.size > 1 ? splittedLastNode[1] : ""} + currentNodes << (JSON.parse(afterNode.to_json)) + end + + # After processing of matches inside line + # Add \n at end of last node for preserve carriage return + lastNode = currentNodes[currentNodes.size - 1].as_h + lastNode["text"] = JSON.parse("#{currentNodes[currentNodes.size - 1]["text"]}\n".to_json) + currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) + + # Finally add final nodes to nodes returned + currentNodes.each do |node| + nodes << (node) + end + end + return JSON.parse({"runs" => nodes}.to_json) +end + +def parse_content(content : JSON::Any, video_id : String? = "") : String + content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || + content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r, video_id).try &.to_s.gsub("\n", "
    ") } || "" +end + +def content_to_comment_html(content, video_id : String? = "") + html_array = content.map do |run| + # Sometimes, there is an empty element. + # See: https://github.com/iv-org/invidious/issues/3096 + next if run.as_h.empty? + + text = HTML.escape(run["text"].as_s) + + if navigationEndpoint = run.dig?("navigationEndpoint") + text = parse_link_endpoint(navigationEndpoint, text, video_id) + end + + text = "#{text}" if run["bold"]? + text = "#{text}" if run["strikethrough"]? + text = "#{text}" if run["italics"]? + + # check for custom emojis + if run["emoji"]? + if run["emoji"]["isCustomEmoji"]?.try &.as_bool + if emojiImage = run.dig?("emoji", "image") + emojiAlt = emojiImage.dig?("accessibility", "accessibilityData", "label").try &.as_s || text + emojiThumb = emojiImage["thumbnails"][0] + text = String.build do |str| + str << %() << emojiAlt << ) + end + else + # Hide deleted channel emoji + text = "" + end + end + end + + text + end + + return html_array.join("").delete('\ufeff') +end -- cgit v1.2.3 From 898066407d85a2844c87fa6fc0e8179977cabb9c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 29 May 2023 12:41:53 +0200 Subject: Utils: Update 'decode_date' to take into account short "x ago" forms --- src/invidious/helpers/utils.cr | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index bcf7c963..48bf769f 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -111,24 +111,27 @@ def decode_date(string : String) else nil # Continue end - # String matches format "20 hours ago", "4 months ago"... - date = string.split(" ")[-3, 3] - delta = date[0].to_i + # String matches format "20 hours ago", "4 months ago", "20s ago", "15min ago"... + match = string.match(/(?\d+) ?(?[smhdwy]\w*) ago/) - case date[1] - when .includes? "second" + raise "Could not parse #{string}" if match.nil? + + delta = match["count"].to_i + + case match["span"] + when .starts_with? "s" # second(s) delta = delta.seconds - when .includes? "minute" + when .starts_with? "mi" # minute(s) delta = delta.minutes - when .includes? "hour" + when .starts_with? "h" # hour(s) delta = delta.hours - when .includes? "day" + when .starts_with? "d" # day(s) delta = delta.days - when .includes? "week" + when .starts_with? "w" # week(s) delta = delta.weeks - when .includes? "month" + when .starts_with? "mo" # month(s) delta = delta.months - when .includes? "year" + when .starts_with? "y" # year(s) delta = delta.years else raise "Could not parse #{string}" -- cgit v1.2.3 From 372192eabc9a23373023d0ed9209059138bb4e66 Mon Sep 17 00:00:00 2001 From: Emilien Devos Date: Sun, 4 Jun 2023 17:13:48 +0200 Subject: warn about hmac key deadline --- src/invidious.cr | 9 +++++++-- src/invidious/views/template.ecr | 8 ++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index b5abd5c7..27c4775e 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -57,8 +57,9 @@ end # Simple alias to make code easier to read alias IV = Invidious -CONFIG = Config.load -HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) +CONFIG = Config.load +HMAC_KEY_CONFIGURED = CONFIG.hmac_key != nil +HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) PG_DB = DB.open CONFIG.database_url ARCHIVE_URL = URI.parse("https://archive.org") @@ -230,6 +231,10 @@ Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.confi Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port Kemal.config.app_name = "Invidious" +if !HMAC_KEY_CONFIGURED + LOGGER.warn("Please configure hmac_key by July 1st, see more here: https://github.com/iv-org/invidious/issues/3854") +end + # Use in kemal's production mode. # Users can also set the KEMAL_ENV environmental variable for this to be set automatically. {% if flag?(:release) || flag?(:production) %} diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 77265679..aa0fc15f 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -111,6 +111,14 @@
    <% end %> + <% if env.get? "user" %> + <% if !HMAC_KEY_CONFIGURED && CONFIG.admins.includes? env.get("user").as(Invidious::User).email %> +
    +

    Message for admin: please configure hmac_key, see more here.

    +
    + <% end %> + <% end %> + <%= content %>
    -- cgit v1.2.3 From d16477602448f7f5ca0f04ffcebf3100575bf703 Mon Sep 17 00:00:00 2001 From: Chunky programmer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 6 Jun 2023 16:27:26 -0400 Subject: Playlists: Fix paging for Invidious playlists --- src/invidious/routes/playlists.cr | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 8675fa45..a65ff64c 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -410,8 +410,13 @@ module Invidious::Routes::Playlists return error_template(500, ex) end - page_count = (playlist.video_count / 200).to_i - page_count += 1 if (playlist.video_count % 200) > 0 + if playlist.is_a? InvidiousPlaylist + page_count = (playlist.video_count / 100).to_i + page_count += 1 if (playlist.video_count % 100) > 0 + else + page_count = (playlist.video_count / 200).to_i + page_count += 1 if (playlist.video_count % 200) > 0 + end if page > page_count return env.redirect "/playlist?list=#{plid}&page=#{page_count}" @@ -422,7 +427,11 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(playlist, offset: (page - 1) * 200) + if playlist.is_a? InvidiousPlaylist + videos = get_playlist_videos(playlist, offset: (page - 1) * 100) + else + videos = get_playlist_videos(playlist, offset: (page - 1) * 200) + end rescue ex return error_template(500, "Error encountered while retrieving playlist videos.
    #{ex.message}") end -- cgit v1.2.3 From 233bd3f593c6311fb524b584a4d0da42f9ff558e Mon Sep 17 00:00:00 2001 From: Chunky programmer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Wed, 7 Jun 2023 09:55:09 -0400 Subject: Watch: Load watch page data for premieres --- src/invidious/videos.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 0038a97a..f38b33e5 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -394,7 +394,9 @@ def fetch_video(id, region) if reason = info["reason"]? if reason == "Video unavailable" raise NotFoundException.new(reason.as_s || "") - else + elsif !reason.as_s.starts_with? "Premieres" + # dont error when it's a premiere. + # we already parsed most of the data and display the premiere date raise InfoException.new(reason.as_s || "") end end -- cgit v1.2.3 From 505a1566d1769720e9d4c4a9899811f323f6f650 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 2 Jun 2023 21:02:58 +0200 Subject: Misc: Update User-Agent string --- src/invidious/yt_backend/connection_pool.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 46e5bf85..b4c1878c 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -8,8 +8,9 @@ def add_yt_headers(request) if request.headers["User-Agent"] == "Crystal" - request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36" + request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36" end + request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" request.headers["Accept-Language"] ||= "en-us,en;q=0.5" -- cgit v1.2.3 From d9521c82cfcf4fb40323938a94d74aa552a2f517 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 2 Jun 2023 23:24:19 +0200 Subject: YT API: Bump iOS app version --- src/invidious/yt_backend/youtube_api.cr | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 91a9332c..a1a54d60 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -12,11 +12,13 @@ module YoutubeAPI private ANDROID_USER_AGENT = "com.google.android.youtube/17.33.42 (Linux; U; Android 12; US) gzip" private ANDROID_SDK_VERSION = 31_i64 private ANDROID_VERSION = "12" - private IOS_APP_VERSION = "17.33.2" + + private IOS_APP_VERSION = "18.21.3" # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1330 - private IOS_USER_AGENT = "com.google.ios.youtube/17.33.2 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)" + private IOS_USER_AGENT = "com.google.ios.youtube/18.21.3 (iPhone14,5; U; CPU iOS 15_6 like Mac OS X;)" # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1224 - private IOS_VERSION = "15.6.0.19G71" + private IOS_VERSION = "15.6.0.19G71" + private WINDOWS_VERSION = "10.0" # Enumerate used to select one of the clients supported by the API -- cgit v1.2.3 From b5e30d66d4134731e01c2ceb1ef3f6f91dce1c0b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 2 Jun 2023 23:25:28 +0200 Subject: YT API: Bump Android app version --- src/invidious/yt_backend/youtube_api.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index a1a54d60..399880c7 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -7,9 +7,9 @@ module YoutubeAPI private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" - private ANDROID_APP_VERSION = "17.33.42" + private ANDROID_APP_VERSION = "18.20.38" # github.com/TeamNewPipe/NewPipeExtractor/blob/943b7c033bb9d07ead63ddab4441c287653e4384/extractor/src/main/java/org/schabi/newpipe/extractor/services/youtube/YoutubeParsingHelper.java#L1308 - private ANDROID_USER_AGENT = "com.google.android.youtube/17.33.42 (Linux; U; Android 12; US) gzip" + private ANDROID_USER_AGENT = "com.google.android.youtube/18.20.38 (Linux; U; Android 12; US) gzip" private ANDROID_SDK_VERSION = 31_i64 private ANDROID_VERSION = "12" -- cgit v1.2.3 From 7556cb69f256dc595a889d20387071cf0659aee0 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 2 Jun 2023 23:37:46 +0200 Subject: YT API: Bump WEB/MWEB client versions --- src/invidious/yt_backend/youtube_api.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 399880c7..3dd9e9d8 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -45,7 +45,7 @@ module YoutubeAPI ClientType::Web => { name: "WEB", name_proto: "1", - version: "2.20221118.01.00", + version: "2.20230602.01.00", api_key: DEFAULT_API_KEY, screen: "WATCH_FULL_SCREEN", os_name: "Windows", @@ -65,7 +65,7 @@ module YoutubeAPI ClientType::WebMobile => { name: "MWEB", name_proto: "2", - version: "2.20220805.01.00", + version: "2.20230531.05.00", api_key: DEFAULT_API_KEY, os_name: "Android", os_version: ANDROID_VERSION, -- cgit v1.2.3 From 1b942f4f0a9b9bad3b9447de2adb99401204cc2c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 20:57:36 +0200 Subject: User: Strip empty new lines before parsing CSV --- src/invidious/user/imports.cr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index e4b25156..0a2fe1e2 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -6,7 +6,7 @@ struct Invidious::User # Parse a youtube CSV subscription file def parse_subscription_export_csv(csv_content : String) - rows = CSV.new(csv_content, headers: true) + rows = CSV.new(csv_content.strip('\n'), headers: true) subscriptions = Array(String).new # Counter to limit the amount of imports. @@ -32,10 +32,10 @@ struct Invidious::User def parse_playlist_export_csv(user : User, raw_input : String) # Split the input into head and body content - raw_head, raw_body = raw_input.split("\n\n", limit: 2, remove_empty: true) + raw_head, raw_body = raw_input.strip('\n').split("\n\n", limit: 2, remove_empty: true) # Create the playlist from the head content - csv_head = CSV.new(raw_head, headers: true) + csv_head = CSV.new(raw_head.strip('\n'), headers: true) csv_head.next title = csv_head[4] description = csv_head[5] @@ -51,7 +51,7 @@ struct Invidious::User Invidious::Database::Playlists.update_description(playlist.id, description) # Add each video to the playlist from the body content - csv_body = CSV.new(raw_body, headers: true) + csv_body = CSV.new(raw_body.strip('\n'), headers: true) csv_body.each do |row| video_id = row[0] if playlist -- cgit v1.2.3 From 62bd895562bfe91a402554d567adc4316ee6d1be Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 17:37:44 +0200 Subject: User: Remove broken Google login (HTML form) --- src/invidious/views/user/login.ecr | 36 ------------------------------------ 1 file changed, 36 deletions(-) (limited to 'src') diff --git a/src/invidious/views/user/login.ecr b/src/invidious/views/user/login.ecr index 01d7a210..2b03d280 100644 --- a/src/invidious/views/user/login.ecr +++ b/src/invidious/views/user/login.ecr @@ -7,42 +7,6 @@
    <% case account_type when %> - <% when "google" %> - -
    - <% if email %> - - <% else %> - - "> - <% end %> - - <% if password %> - - <% else %> - - "> - <% end %> - - <% if prompt %> - - - <% end %> - - <% if tfa %> - - <% end %> - - <% if captcha %> - - - - "> - <% end %> - - -
    - <% else # "invidious" %>
    -- cgit v1.2.3 From b2b61ab0a9039f256a3f36cd81af316a514b4ba3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 17:47:33 +0200 Subject: User: Remove broken Google login (login route) --- src/invidious/routes/login.cr | 274 +----------------------------------------- 1 file changed, 2 insertions(+), 272 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index 6454131a..ca1e0d49 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -24,9 +24,6 @@ module Invidious::Routes::Login captcha_type = env.params.query["captcha"]? captcha_type ||= "image" - tfa = env.params.query["tfa"]? - prompt = nil - templated "user/login" end @@ -47,283 +44,18 @@ module Invidious::Routes::Login account_type ||= "invidious" case account_type - when "google" - tfa_code = env.params.body["tfa"]?.try &.lchop("G-") - traceback = IO::Memory.new - - # See https://github.com/ytdl-org/youtube-dl/blob/2019.04.07/youtube_dl/extractor/youtube.py#L82 - begin - client = nil # Declare variable - {% unless flag?(:disable_quic) %} - client = CONFIG.use_quic ? QUIC::Client.new(LOGIN_URL) : HTTP::Client.new(LOGIN_URL) - {% else %} - client = HTTP::Client.new(LOGIN_URL) - {% end %} - - headers = HTTP::Headers.new - - login_page = client.get("/ServiceLogin") - headers = login_page.cookies.add_request_headers(headers) - - lookup_req = { - email, nil, [] of String, nil, "US", nil, nil, 2, false, true, - {nil, nil, - {2, 1, nil, 1, - "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", - nil, [] of String, 4}, - 1, - {nil, nil, [] of String}, - nil, nil, nil, true, - }, - email, - }.to_json - - traceback << "Getting lookup..." - - headers["Content-Type"] = "application/x-www-form-urlencoded;charset=utf-8" - headers["Google-Accounts-XSRF"] = "1" - - response = client.post("/_/signin/sl/lookup", headers, login_req(lookup_req)) - lookup_results = JSON.parse(response.body[5..-1]) - - traceback << "done, returned #{response.status_code}.
    " - - user_hash = lookup_results[0][2] - - if token = env.params.body["token"]? - answer = env.params.body["answer"]? - captcha = {token, answer} - else - captcha = nil - end - - challenge_req = { - user_hash, nil, 1, nil, - {1, nil, nil, nil, - {password, captcha, true}, - }, - {nil, nil, - {2, 1, nil, 1, - "https://accounts.google.com/ServiceLogin?passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Fnext%3D%252F%26action_handle_signin%3Dtrue%26hl%3Den%26app%3Ddesktop%26feature%3Dsign_in_button&hl=en&service=youtube&uilel=3&requestPath=%2FServiceLogin&Page=PasswordSeparationSignIn", - nil, [] of String, 4}, - 1, - {nil, nil, [] of String}, - nil, nil, nil, true, - }, - }.to_json - - traceback << "Getting challenge..." - - response = client.post("/_/signin/sl/challenge", headers, login_req(challenge_req)) - headers = response.cookies.add_request_headers(headers) - challenge_results = JSON.parse(response.body[5..-1]) - - traceback << "done, returned #{response.status_code}.
    " - - headers["Cookie"] = URI.decode_www_form(headers["Cookie"]) - - if challenge_results[0][3]?.try &.== 7 - return error_template(423, "Account has temporarily been disabled") - end - - if token = challenge_results[0][-1]?.try &.[-1]?.try &.as_h?.try &.["5001"]?.try &.[-1].as_a?.try &.[-1].as_s - account_type = "google" - captcha_type = "image" - prompt = nil - tfa = tfa_code - captcha = {tokens: [token], question: ""} - - return templated "user/login" - end - - if challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED" - return error_template(401, "Incorrect password") - end - - prompt_type = challenge_results[0][-1]?.try &.[0].as_a?.try &.[0][2]? - if {"TWO_STEP_VERIFICATION", "LOGIN_CHALLENGE"}.includes? prompt_type - traceback << "Handling prompt #{prompt_type}.
    " - case prompt_type - when "TWO_STEP_VERIFICATION" - prompt_type = 2 - else # "LOGIN_CHALLENGE" - prompt_type = 4 - end - - # Prefer Authenticator app and SMS over unsupported protocols - if !{6, 9, 12, 15}.includes?(challenge_results[0][-1][0][0][8].as_i) && prompt_type == 2 - tfa = challenge_results[0][-1][0].as_a.select { |auth_type| {6, 9, 12, 15}.includes? auth_type[8] }[0] - - traceback << "Selecting challenge #{tfa[8]}..." - select_challenge = {prompt_type, nil, nil, nil, {tfa[8]}}.to_json - - tl = challenge_results[1][2] - - tfa = client.post("/_/signin/selectchallenge?TL=#{tl}", headers, login_req(select_challenge)).body - tfa = tfa[5..-1] - tfa = JSON.parse(tfa)[0][-1] - - traceback << "done.
    " - else - traceback << "Using challenge #{challenge_results[0][-1][0][0][8]}.
    " - tfa = challenge_results[0][-1][0][0] - end - - if tfa[5] == "QUOTA_EXCEEDED" - return error_template(423, "Quota exceeded, try again in a few hours") - end - - if !tfa_code - account_type = "google" - captcha_type = "image" - - case tfa[8] - when 6, 9 - prompt = "Google verification code" - when 12 - prompt = "Login verification, recovery email: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" - when 15 - prompt = "Login verification, security question: #{tfa[-1][tfa[-1].as_h.keys[0]][0]}" - else - prompt = "Google verification code" - end - - tfa = nil - captcha = nil - return templated "user/login" - end - - tl = challenge_results[1][2] - - request_type = tfa[8] - case request_type - when 6 # Authenticator app - tfa_req = { - user_hash, nil, 2, nil, - {6, nil, nil, nil, nil, - {tfa_code, false}, - }, - }.to_json - when 9 # Voice or text message - tfa_req = { - user_hash, nil, 2, nil, - {9, nil, nil, nil, nil, nil, nil, nil, - {nil, tfa_code, false, 2}, - }, - }.to_json - when 12 # Recovery email - tfa_req = { - user_hash, nil, 4, nil, - {12, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - {tfa_code}, - }, - }.to_json - when 15 # Security question - tfa_req = { - user_hash, nil, 5, nil, - {15, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, - {tfa_code}, - }, - }.to_json - else - return error_template(500, "Unable to log in, make sure two-factor authentication (Authenticator or SMS) is turned on.") - end - - traceback << "Submitting challenge..." - - response = client.post("/_/signin/challenge?hl=en&TL=#{tl}", headers, login_req(tfa_req)) - headers = response.cookies.add_request_headers(headers) - challenge_results = JSON.parse(response.body[5..-1]) - - if (challenge_results[0][-1]?.try &.[5] == "INCORRECT_ANSWER_ENTERED") || - (challenge_results[0][-1]?.try &.[5] == "INVALID_INPUT") - return error_template(401, "Invalid TFA code") - end - - traceback << "done.
    " - end - - traceback << "Logging in..." - - location = URI.parse(challenge_results[0][-1][2].to_s) - cookies = HTTP::Cookies.from_client_headers(headers) - - headers.delete("Content-Type") - headers.delete("Google-Accounts-XSRF") - - loop do - if !location || location.path == "/ManageAccount" - break - end - - # Occasionally there will be a second page after login confirming - # the user's phone number ("/b/0/SmsAuthInterstitial"), which we currently don't handle. - - if location.path.starts_with? "/b/0/SmsAuthInterstitial" - traceback << "Unhandled dialog /b/0/SmsAuthInterstitial." - end - - login = client.get(location.request_target, headers) - - headers = login.cookies.add_request_headers(headers) - location = login.headers["Location"]?.try { |u| URI.parse(u) } - end - - cookies = HTTP::Cookies.from_client_headers(headers) - sid = cookies["SID"]?.try &.value - if !sid - raise "Couldn't get SID." - end - - user, sid = get_user(sid, headers) - - # We are now logged in - traceback << "done.
    " - - host = URI.parse(env.request.headers["Host"]).host - - cookies.each do |cookie| - cookie.secure = Invidious::User::Cookies::SECURE - - if cookie.extension - cookie.extension = cookie.extension.not_nil!.gsub(".youtube.com", host) - cookie.extension = cookie.extension.not_nil!.gsub("Secure; ", "") - end - env.response.cookies << cookie - end - - if env.request.cookies["PREFS"]? - user.preferences = env.get("preferences").as(Preferences) - Invidious::Database::Users.update_preferences(user) - - cookie = env.request.cookies["PREFS"] - cookie.expires = Time.utc(1990, 1, 1) - env.response.cookies << cookie - end - - env.redirect referer - rescue ex - traceback.rewind - # error_message = translate(locale, "Login failed. This may be because two-factor authentication is not turned on for your account.") - error_message = %(#{ex.message}
    Traceback:
    #{traceback.gets_to_end}
    ) - return error_template(500, error_message) - end when "invidious" - if !email + if email.nil? || email.empty? return error_template(401, "User ID is a required field") end - if !password + if password.nil? || password.empty? return error_template(401, "Password is a required field") end user = Invidious::Database::Users.select(email: email) if user - if !user.password - return error_template(400, "Please sign in using 'Log in with Google'") - end - if Crypto::Bcrypt::Password.new(user.password.not_nil!).verify(password.byte_slice(0, 55)) sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) Invidious::Database::SessionIDs.insert(sid, email) @@ -367,8 +99,6 @@ module Invidious::Routes::Login captcha_type ||= "image" account_type = "invidious" - tfa = false - prompt = "" if captcha_type == "image" captcha = Invidious::User::Captcha.generate_image(HMAC_KEY) -- cgit v1.2.3 From d3b04ac68c7d85dae0e1e15611666d7c055e2c12 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 17:48:10 +0200 Subject: User: Remove broken Google login (dedicated captcha route) --- src/invidious/routes/login.cr | 7 ------- src/invidious/routing.cr | 1 - 2 files changed, 8 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/login.cr b/src/invidious/routes/login.cr index ca1e0d49..d0f7ac22 100644 --- a/src/invidious/routes/login.cr +++ b/src/invidious/routes/login.cr @@ -211,11 +211,4 @@ module Invidious::Routes::Login env.redirect referer end - - def self.captcha(env) - headers = HTTP::Headers{":authority" => "accounts.google.com"} - response = YT_POOL.client &.get(env.request.resource, headers) - env.response.headers["Content-Type"] = response.headers["Content-Type"] - response.body - end end diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 72ee9194..daaf4d88 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -57,7 +57,6 @@ module Invidious::Routing get "/login", Routes::Login, :login_page post "/login", Routes::Login, :login post "/signout", Routes::Login, :signout - get "/Captcha", Routes::Login, :captcha # User preferences get "/preferences", Routes::PreferencesRoute, :show -- cgit v1.2.3 From 836898754e35957b9bcec5acc055e0993da8e37b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 18:00:22 +0200 Subject: User: Remove broken Google login (before_all route) --- src/invidious/routes/before_all.cr | 60 +++++++++++--------------------------- 1 file changed, 17 insertions(+), 43 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index 8e2a253f..396840a4 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -80,49 +80,23 @@ module Invidious::Routes::BeforeAll raise "Cannot use token as SID" end - # Invidious users only have SID - if !env.request.cookies.has_key? "SSID" - if email = Invidious::Database::SessionIDs.select_email(sid) - user = Invidious::Database::Users.select!(email: email) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, 1.week) - - preferences = user.preferences - env.set "preferences", preferences - - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - end - else - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - begin - user, sid = get_user(sid, headers, false) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, 1.week) - - preferences = user.preferences - env.set "preferences", preferences - - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - rescue ex - end + if email = Database::SessionIDs.select_email(sid) + user = Database::Users.select!(email: email) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, 1.week) + + preferences = user.preferences + env.set "preferences", preferences + + env.set "sid", sid + env.set "csrf_token", csrf_token + env.set "user", user end end -- cgit v1.2.3 From fcbd5106c3583601d09ddbaa07a12e4b73552200 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 18:00:42 +0200 Subject: User: Remove broken Google login (password change route) --- src/invidious/routes/account.cr | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index 5aa4452c..9d930841 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -42,11 +42,6 @@ module Invidious::Routes::Account sid = sid.as(String) token = env.params.body["csrf_token"]? - # We don't store passwords for Google accounts - if !user.password - return error_template(400, "Cannot change password for Google accounts") - end - begin validate_request(token, sid, env.request, HMAC_KEY, locale) rescue ex @@ -54,7 +49,7 @@ module Invidious::Routes::Account end password = env.params.body["password"]? - if !password + if password.nil? || password.empty? return error_template(401, "Password is a required field") end -- cgit v1.2.3 From 9dd4195dd0089216a42214c7b227398906ad7535 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 18:05:34 +0200 Subject: User: Remove broken Google login (subscribe route) --- src/invidious/routes/subscriptions.cr | 13 ------------- src/invidious/users.cr | 32 -------------------------------- 2 files changed, 45 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/subscriptions.cr b/src/invidious/routes/subscriptions.cr index 0704c05e..7f9ec592 100644 --- a/src/invidious/routes/subscriptions.cr +++ b/src/invidious/routes/subscriptions.cr @@ -43,11 +43,6 @@ module Invidious::Routes::Subscriptions channel_id = env.params.query["c"]? channel_id ||= "" - if !user.password - # Sync subscriptions with YouTube - subscribe_ajax(channel_id, action, env.request.headers) - end - case action when "action_create_subscription_to_channel" if !user.subscriptions.includes? channel_id @@ -82,14 +77,6 @@ module Invidious::Routes::Subscriptions user = user.as(User) sid = sid.as(String) - if !user.password - # Refresh account - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - user, sid = get_user(sid, headers) - end - action_takeout = env.params.query["action_takeout"]?.try &.to_i? action_takeout ||= 0 action_takeout = action_takeout == 1 diff --git a/src/invidious/users.cr b/src/invidious/users.cr index b763596b..dc36c61e 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -91,38 +91,6 @@ def create_user(sid, email, password) return user, sid end -def subscribe_ajax(channel_id, action, env_headers) - headers = HTTP::Headers.new - headers["Cookie"] = env_headers["Cookie"] - - html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) - - cookies = HTTP::Cookies.from_client_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': "(?[^"]+)"/) - session_token = match["session_token"] - - headers["content-type"] = "application/x-www-form-urlencoded" - - post_req = { - session_token: session_token, - } - post_url = "/subscription_ajax?#{action}=1&c=#{channel_id}" - - YT_POOL.client &.post(post_url, headers, form: post_req) - end -end - def get_subscription_feed(user, max_results = 40, page = 1) limit = max_results.clamp(0, MAX_ITEMS_PER_PAGE) offset = (page - 1) * limit -- cgit v1.2.3 From 11ab6ffb32a99df287da0c13f08c8433e6ba067b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 18:07:07 +0200 Subject: User: Remove broken Google login (notifications route) --- src/invidious/routes/notifications.cr | 44 ----------------------------------- 1 file changed, 44 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/notifications.cr b/src/invidious/routes/notifications.cr index 272a3dc7..8922b740 100644 --- a/src/invidious/routes/notifications.cr +++ b/src/invidious/routes/notifications.cr @@ -24,50 +24,6 @@ module Invidious::Routes::Notifications user = user.as(User) - if !user.password - channel_req = {} of String => String - - channel_req["receive_all_updates"] = env.params.query["receive_all_updates"]? || "true" - channel_req["receive_no_updates"] = env.params.query["receive_no_updates"]? || "" - channel_req["receive_post_updates"] = env.params.query["receive_post_updates"]? || "true" - - channel_req.reject! { |k, v| v != "true" && v != "false" } - - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - html = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) - - cookies = HTTP::Cookies.from_client_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': "(?[^"]+)"/) - session_token = match["session_token"] - else - return env.redirect referer - end - - headers["content-type"] = "application/x-www-form-urlencoded" - channel_req["session_token"] = session_token - - subs = XML.parse_html(html.body) - subs.xpath_nodes(%q(//a[@class="subscription-title yt-uix-sessionlink"]/@href)).each do |channel| - channel_id = channel.content.lstrip("/channel/").not_nil! - channel_req["channel_id"] = channel_id - - YT_POOL.client &.post("/subscription_ajax?action_update_subscription_preferences=1", headers, form: channel_req) - end - end - if redirect env.redirect referer else -- cgit v1.2.3 From 39ff94362e951cf69acb3ab56f2c0d378ca1fcc5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 19:32:06 +0200 Subject: User: Remove broken Google login (feeds route) --- src/invidious/routes/feeds.cr | 4 ---- 1 file changed, 4 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index fb482e33..fc62c5a3 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -83,10 +83,6 @@ module Invidious::Routes::Feeds headers = HTTP::Headers.new headers["Cookie"] = env.request.headers["Cookie"] - if !user.password - user, sid = get_user(sid, headers) - end - max_results = env.params.query["max_results"]?.try &.to_i?.try &.clamp(0, MAX_ITEMS_PER_PAGE) max_results ||= user.preferences.max_results max_results ||= CONFIG.default_user_preferences.max_results -- cgit v1.2.3 From 34441178182cb96e93d679230103005b86e3b35b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 18:13:45 +0200 Subject: User: Remove broken Google login (various constants) --- src/invidious.cr | 1 - src/invidious/yt_backend/connection_pool.cr | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 27c4775e..636e28a6 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -63,7 +63,6 @@ HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) PG_DB = DB.open CONFIG.database_url ARCHIVE_URL = URI.parse("https://archive.org") -LOGIN_URL = URI.parse("https://accounts.google.com") PUBSUB_URL = URI.parse("https://pubsubhubbub.appspot.com") REDDIT_URL = URI.parse("https://www.reddit.com") YT_URL = URI.parse("https://www.youtube.com") diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index b4c1878c..658731cf 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -14,8 +14,9 @@ def add_yt_headers(request) request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" request.headers["Accept-Language"] ||= "en-us,en;q=0.5" + # Preserve original cookies and add new YT consent cookie for EU servers - request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=YES+" + request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}" if !CONFIG.cookies.empty? request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" end -- cgit v1.2.3 From 69f23d95b8ab719ca4f19649ce105fa29786913c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 18:04:16 +0200 Subject: User: Remove broken Google login (various functions) --- src/invidious/helpers/helpers.cr | 25 --------------- src/invidious/users.cr | 69 ---------------------------------------- 2 files changed, 94 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/helpers.cr b/src/invidious/helpers/helpers.cr index c3b53339..23ff0da9 100644 --- a/src/invidious/helpers/helpers.cr +++ b/src/invidious/helpers/helpers.cr @@ -22,31 +22,6 @@ struct Annotation property annotations : String end -def login_req(f_req) - data = { - # Unfortunately there's not much information available on `bgRequest`; part of Google's BotGuard - # Generally this is much longer (>1250 characters), see also - # https://github.com/ytdl-org/youtube-dl/commit/baf67a604d912722b0fe03a40e9dc5349a2208cb . - # For now this can be empty. - "bgRequest" => %|["identifier",""]|, - "pstMsg" => "1", - "checkConnection" => "youtube", - "checkedDomains" => "youtube", - "hl" => "en", - "deviceinfo" => %|[null,null,null,[],null,"US",null,null,[],"GlifWebSignIn",null,[null,null,[]]]|, - "f.req" => f_req, - "flowName" => "GlifWebSignIn", - "flowEntry" => "ServiceLogin", - # "cookiesDisabled" => "false", - # "gmscoreversion" => "undefined", - # "continue" => "https://accounts.google.com/ManageAccount", - # "azt" => "", - # "bgHash" => "", - } - - return HTTP::Params.encode(data) -end - def html_to_content(description_html : String) description = description_html.gsub(/(
    )|()/, { "
    ": "\n", diff --git a/src/invidious/users.cr b/src/invidious/users.cr index dc36c61e..65566d20 100644 --- a/src/invidious/users.cr +++ b/src/invidious/users.cr @@ -3,75 +3,6 @@ require "crypto/bcrypt/password" # Materialized views may not be defined using bound parameters (`$1` as used elsewhere) MATERIALIZED_VIEW_SQL = ->(email : String) { "SELECT cv.* FROM channel_videos cv WHERE EXISTS (SELECT subscriptions FROM users u WHERE cv.ucid = ANY (u.subscriptions) AND u.email = E'#{email.gsub({'\'' => "\\'", '\\' => "\\\\"})}') ORDER BY published DESC" } -def get_user(sid, headers, refresh = true) - if email = Invidious::Database::SessionIDs.select_email(sid) - user = Invidious::Database::Users.select!(email: email) - - if refresh && Time.utc - user.updated > 1.minute - user, sid = fetch_user(sid, headers) - - Invidious::Database::Users.insert(user, update_on_conflict: true) - Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) - - begin - view_name = "subscriptions_#{sha256(user.email)}" - PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - rescue ex - end - end - else - user, sid = fetch_user(sid, headers) - - Invidious::Database::Users.insert(user, update_on_conflict: true) - Invidious::Database::SessionIDs.insert(sid, user.email, handle_conflicts: true) - - begin - view_name = "subscriptions_#{sha256(user.email)}" - PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") - rescue ex - end - end - - return user, sid -end - -def fetch_user(sid, headers) - feed = YT_POOL.client &.get("/subscription_manager?disable_polymer=1", headers) - feed = XML.parse_html(feed.body) - - channels = feed.xpath_nodes(%q(//ul[@id="guide-channels"]/li/a)).compact_map do |channel| - if {"Popular on YouTube", "Music", "Sports", "Gaming"}.includes? channel["title"] - nil - else - channel["href"].lstrip("/channel/") - end - end - - channels = get_batch_channels(channels) - - email = feed.xpath_node(%q(//a[@class="yt-masthead-picker-header yt-masthead-picker-active-account"])) - if email - email = email.content.strip - else - email = "" - end - - token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) - - user = Invidious::User.new({ - updated: Time.utc, - notifications: [] of String, - subscriptions: channels, - email: email, - preferences: Preferences.new(CONFIG.default_user_preferences.to_tuple), - password: nil, - token: token, - watched: [] of String, - feed_needs_update: true, - }) - return user, sid -end - def create_user(sid, email, password) password = Crypto::Bcrypt::Password.create(password, cost: 10) token = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) -- cgit v1.2.3 From b06c87ff8d1b799de8926d8b965cc1223b52a3de Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 10 Jun 2023 17:59:50 +0200 Subject: User: Remove broken Google login (various comments) --- config/config.example.yml | 3 +-- src/invidious/routes/api/v1/authenticated.cr | 4 ---- src/invidious/routes/playlists.cr | 4 ---- src/invidious/views/privacy.ecr | 3 +-- 4 files changed, 2 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 7ea80017..c591eb6a 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -255,8 +255,7 @@ https_only: false #registration_enabled: true ## -## Allow/Forbid users to log-in. This setting affects the ability -## to connect with BOTH Google and Invidious (local) accounts. +## Allow/Forbid users to log-in. ## ## Accepted values: true, false ## Default: true diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index ce2ee812..a35d2f2b 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -178,10 +178,6 @@ module Invidious::Routes::API::V1::Authenticated Invidious::Database::Users.subscribe_channel(user, ucid) end - # For Google accounts, access tokens don't have enough information to - # make a request on the user's behalf, which is why we don't sync with - # YouTube. - env.response.status_code = 204 end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 8675fa45..1dd3f32e 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -320,10 +320,6 @@ module Invidious::Routes::Playlists 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 diff --git a/src/invidious/views/privacy.ecr b/src/invidious/views/privacy.ecr index 643f880b..bc5ff40b 100644 --- a/src/invidious/views/privacy.ecr +++ b/src/invidious/views/privacy.ecr @@ -16,12 +16,11 @@
  • a list of channel UCIDs the user is subscribed to
  • a user ID (for persistent storage of subscriptions and preferences)
  • a json object containing user preferences
  • -
  • a hashed password if applicable (not present on google accounts)
  • +
  • a hashed password
  • a randomly generated token for providing an RSS feed of a user's subscriptions
  • a list of video IDs identifying watched videos
  • Users can clear their watch history using the clear watch history page.

    -

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

    Data you passively provide

    When you request any resource from this website (for example: a page, a font, an image, or an API endpoint) information about the request may be logged.

    -- cgit v1.2.3 From 8e4833d21a08b9a25cd15738a399c64bc5575fa6 Mon Sep 17 00:00:00 2001 From: Emilien Devos <4016501+unixfox@users.noreply.github.com> Date: Sun, 11 Jun 2023 16:37:27 +0200 Subject: temp explanation about video not available issue --- src/invidious/videos/parser.cr | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 2e8eecc3..9cc0ffdc 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -78,7 +78,11 @@ def extract_video_info(video_id : String, proxy_region : String? = nil) elsif video_id != player_response.dig("videoDetails", "videoId") # YouTube may return a different video player response than expected. # See: https://github.com/TeamNewPipe/NewPipe/issues/8713 - raise VideoNotAvailableException.new("The video returned by YouTube isn't the requested one. (WEB client)") + # Line to be reverted if one day we solve the video not available issue. + return { + "version" => JSON::Any.new(Video::SCHEMA_VERSION.to_i64), + "reason" => JSON::Any.new("Can't load the video on this Invidious instance. YouTube is currently trying to block Invidious instances. Click here for more info about the issue."), + } else reason = nil end -- cgit v1.2.3 From 7a569d81ca0877ac081d7aa89a2acaf7f6d08940 Mon Sep 17 00:00:00 2001 From: lamemakes Date: Mon, 12 Jun 2023 09:40:26 -0400 Subject: Updated comment link returns --- src/invidious/helpers/utils.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 48bf769f..a006d602 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -440,7 +440,7 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String) # - https://github.com/iv-org/invidious/issues/3062 text = %(#{text}) else - text = %(#{reduce_uri(url)}) + text = %(#{reduce_uri(text)}) end end return text -- cgit v1.2.3 From 495ccdc221205572dd4d34d94b0d9e3d27a79e7a Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Tue, 13 Jun 2023 19:16:07 +0900 Subject: Fix typo in jobs.cr follwing -> following --- src/invidious/jobs.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/jobs.cr b/src/invidious/jobs.cr index 524a3624..b6b673f7 100644 --- a/src/invidious/jobs.cr +++ b/src/invidious/jobs.cr @@ -2,7 +2,7 @@ module Invidious::Jobs JOBS = [] of BaseJob # Automatically generate a structure that wraps the various - # jobs' configs, so that the follwing YAML config can be used: + # jobs' configs, so that the following YAML config can be used: # # jobs: # job_name: -- cgit v1.2.3 From 16b8b6034fed818bc05fbdea80b50bb04e1055f7 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 21 Jun 2023 21:41:53 +0200 Subject: Channels: Use new ctoken value for "sort by oldest" --- src/invidious/channels/videos.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index 12ed4a7d..beb86e08 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -20,7 +20,7 @@ def produce_channel_videos_continuation(ucid, page = 1, auto_generated = nil, so case sort_by when "newest" then 1_i64 when "popular" then 2_i64 - when "oldest" then 3_i64 # Broken as of 10/2022 :c + when "oldest" then 4_i64 else 1_i64 # Fallback to "newest" end -- cgit v1.2.3 From 1647092b3c77ac271f4b50a3ee9bdfd3d6be4345 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 1 Jul 2023 19:29:24 +0200 Subject: Config: Make 'hmac_key' mandatory --- src/invidious/config.cr | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 9fc58409..7030c925 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -85,7 +85,7 @@ class Config # Used to tell Invidious it is behind a proxy, so links to resources should be https:// property https_only : Bool? # HMAC signing key for CSRF tokens and verifying pubsub subscriptions - property hmac_key : String? + property hmac_key : String = "" # Domain to be used for links to resources on the site where an absolute URL is required property domain : String? # Subscribe to channels using PubSubHubbub (requires domain, hmac_key) @@ -204,6 +204,13 @@ class Config end {% end %} + # HMAC_key is mandatory + # See: https://github.com/iv-org/invidious/issues/3854 + if config.hmac_key.empty? + puts "Config: 'hmac_key' is required/can't be empty" + exit(1) + end + # Build database_url from db.* if it's not set directly if config.database_url.to_s.empty? if db = config.db @@ -216,7 +223,7 @@ class Config path: db.dbname, ) else - puts "Config : Either database_url or db.* is required" + puts "Config: Either database_url or db.* is required" exit(1) end end -- cgit v1.2.3 From ba43365acb20ee4fe1b94e9457595fa6e30ae8f9 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 1 Jul 2023 19:38:50 +0200 Subject: Config: Stop if 'hmac_key' is the default value --- src/invidious/config.cr | 3 +++ 1 file changed, 3 insertions(+) (limited to 'src') diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 7030c925..e5f1e822 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -209,6 +209,9 @@ class Config if config.hmac_key.empty? puts "Config: 'hmac_key' is required/can't be empty" exit(1) + elsif config.hmac_key == "CHANGE_ME!!" + puts "Config: The value of 'hmac_key' needs to be changed!!" + exit(1) end # Build database_url from db.* if it's not set directly -- cgit v1.2.3 From d7568ac45a77323e724d0664d8959d9c1e8fa04c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 1 Jul 2023 21:53:56 +0200 Subject: Remove old warning code about unconfigured 'hmac_key' --- src/invidious.cr | 9 ++------- src/invidious/views/template.ecr | 8 -------- 2 files changed, 2 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 636e28a6..84e1895d 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -57,9 +57,8 @@ end # Simple alias to make code easier to read alias IV = Invidious -CONFIG = Config.load -HMAC_KEY_CONFIGURED = CONFIG.hmac_key != nil -HMAC_KEY = CONFIG.hmac_key || Random::Secure.hex(32) +CONFIG = Config.load +HMAC_KEY = CONFIG.hmac_key PG_DB = DB.open CONFIG.database_url ARCHIVE_URL = URI.parse("https://archive.org") @@ -230,10 +229,6 @@ Kemal.config.host_binding = Kemal.config.host_binding != "0.0.0.0" ? Kemal.confi Kemal.config.port = Kemal.config.port != 3000 ? Kemal.config.port : CONFIG.port Kemal.config.app_name = "Invidious" -if !HMAC_KEY_CONFIGURED - LOGGER.warn("Please configure hmac_key by July 1st, see more here: https://github.com/iv-org/invidious/issues/3854") -end - # Use in kemal's production mode. # Users can also set the KEMAL_ENV environmental variable for this to be set automatically. {% if flag?(:release) || flag?(:production) %} diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index aa0fc15f..77265679 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -111,14 +111,6 @@
    <% end %> - <% if env.get? "user" %> - <% if !HMAC_KEY_CONFIGURED && CONFIG.admins.includes? env.get("user").as(Invidious::User).email %> -
    -

    Message for admin: please configure hmac_key, see more here.

    -
    - <% end %> - <% end %> - <%= content %>
    -- cgit v1.2.3 From a38edd733002166d334261abb39a220c8972ca25 Mon Sep 17 00:00:00 2001 From: Omer Naveed Date: Sat, 1 Jul 2023 12:29:02 -0500 Subject: Fix Nil assertion failed in RSS feeds --- src/invidious/routes/feeds.cr | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index fc62c5a3..60f8db05 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -154,20 +154,26 @@ module Invidious::Routes::Feeds return error_atom(500, ex) end + namespaces = { + "yt" => "http://www.youtube.com/xml/schemas/2015", + "media" => "http://search.yahoo.com/mrss/", + "default" => "http://www.w3.org/2005/Atom", + } + response = YT_POOL.client &.get("/feeds/videos.xml?channel_id=#{channel.ucid}") - rss = XML.parse_html(response.body) + rss = XML.parse(response.body) - videos = rss.xpath_nodes("//feed/entry").map do |entry| - video_id = entry.xpath_node("videoid").not_nil!.content - title = entry.xpath_node("title").not_nil!.content + videos = rss.xpath_nodes("//default:feed/default:entry", namespaces).map do |entry| + video_id = entry.xpath_node("yt:videoId", namespaces).not_nil!.content + title = entry.xpath_node("default:title", namespaces).not_nil!.content - published = Time.parse_rfc3339(entry.xpath_node("published").not_nil!.content) - updated = Time.parse_rfc3339(entry.xpath_node("updated").not_nil!.content) + published = Time.parse_rfc3339(entry.xpath_node("default:published", namespaces).not_nil!.content) + updated = Time.parse_rfc3339(entry.xpath_node("default:updated", namespaces).not_nil!.content) - author = entry.xpath_node("author/name").not_nil!.content - ucid = entry.xpath_node("channelid").not_nil!.content - description_html = entry.xpath_node("group/description").not_nil!.to_s - views = entry.xpath_node("group/community/statistics").not_nil!.["views"].to_i64 + author = entry.xpath_node("default:author/default:name", namespaces).not_nil!.content + ucid = entry.xpath_node("yt:channelId", namespaces).not_nil!.content + description_html = entry.xpath_node("media:group/media:description", namespaces).not_nil!.to_s + views = entry.xpath_node("media:group/media:community/media:statistics", namespaces).not_nil!.["views"].to_i64 SearchVideo.new({ title: title, -- cgit v1.2.3 From 0ba22ef391a7b350d139dfd256aa20a7e1f812ed Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 18 Apr 2023 00:04:49 +0200 Subject: I18n: Add a function to determine if a given locale is RTL --- src/invidious/helpers/i18n.cr | 9 +++++++++ 1 file changed, 9 insertions(+) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index a9ed1f64..76e477a4 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -165,3 +165,12 @@ def translate_bool(locale : String?, translation : Bool) return translate(locale, "No") end end + +def locale_is_rtl?(locale : String?) + # Fallback to en-US + return false if locale.nil? + + # Arabic, Persian, Hebrew + # See https://en.wikipedia.org/wiki/Right-to-left_script#List_of_RTL_scripts + return {"ar", "fa", "he"}.includes? locale +end -- cgit v1.2.3 From 462609d90d38ec8e9aada1d700cfbca46e906552 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 26 Apr 2023 22:30:13 +0200 Subject: Utils: Create a function to append parameters to a base URL --- src/invidious/http_server/utils.cr | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) (limited to 'src') diff --git a/src/invidious/http_server/utils.cr b/src/invidious/http_server/utils.cr index e3f1fa0f..222dfc4a 100644 --- a/src/invidious/http_server/utils.cr +++ b/src/invidious/http_server/utils.cr @@ -1,3 +1,5 @@ +require "uri" + module Invidious::HttpServer module Utils extend self @@ -16,5 +18,23 @@ module Invidious::HttpServer return "#{url.request_target}?#{params}" end end + + def add_params_to_url(url : String | URI, params : URI::Params) : URI + url = URI.parse(url) if url.is_a?(String) + + url_query = url.query || "" + + # Append the parameters + url.query = String.build do |str| + if !url_query.empty? + str << url_query + str << '&' + end + + str << params + end + + return url + end end end -- cgit v1.2.3 From c0887497447a24cad1f1e8b8268b8ccfbc78ae77 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 17 Apr 2023 21:25:00 +0200 Subject: HTML: Add code to generate page nav buttons --- src/invidious/frontend/pagination.cr | 97 ++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 src/invidious/frontend/pagination.cr (limited to 'src') diff --git a/src/invidious/frontend/pagination.cr b/src/invidious/frontend/pagination.cr new file mode 100644 index 00000000..3f931f4e --- /dev/null +++ b/src/invidious/frontend/pagination.cr @@ -0,0 +1,97 @@ +require "uri" + +module Invidious::Frontend::Pagination + extend self + + private def previous_page(str : String::Builder, locale : String?, url : String) + # Link + str << %() + + if locale_is_rtl?(locale) + # Inverted arrow ("previous" points to the right) + str << translate(locale, "Previous page") + str << "  " + str << %() + else + # Regular arrow ("previous" points to the left) + str << %() + str << "  " + str << translate(locale, "Previous page") + end + + str << "" + end + + private def next_page(str : String::Builder, locale : String?, url : String) + # Link + str << %() + + if locale_is_rtl?(locale) + # Inverted arrow ("next" points to the left) + str << %() + str << "  " + str << translate(locale, "Next page") + else + # Regular arrow ("next" points to the right) + str << translate(locale, "Next page") + str << "  " + str << %() + end + + str << "" + end + + def nav_numeric(locale : String?, *, base_url : String | URI, current_page : Int, show_next : Bool = true) + return String.build do |str| + str << %(
    \n) + str << %(\n) + str << %(
    \n\n) + end + end + + def nav_ctoken(locale : String?, *, base_url : String | URI, ctoken : String?) + return String.build do |str| + str << %(
    \n) + str << %(\n) + str << %(
    \n\n) + end + end +end -- cgit v1.2.3 From 57c7b922f7c3cd04d08bb6be9793464d31213fb1 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 17 Apr 2023 21:26:04 +0200 Subject: HTML: Make a dedicated ECR component for items + pagination --- src/invidious/views/components/items_paginated.ecr | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/invidious/views/components/items_paginated.ecr (limited to 'src') diff --git a/src/invidious/views/components/items_paginated.ecr b/src/invidious/views/components/items_paginated.ecr new file mode 100644 index 00000000..c82b1772 --- /dev/null +++ b/src/invidious/views/components/items_paginated.ecr @@ -0,0 +1,11 @@ +<%= page_nav_html %> + +
    + <%- videos.each do |item| -%> + <%= rendered "components/item" %> + <%- end -%> +
    + +<%= page_nav_html %> + + -- cgit v1.2.3 From c4ef3bed9556700c4c4e8c02c394d16fd3aae03d Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 18 Apr 2023 00:04:01 +0200 Subject: HTML: Use the new pagination component for playlists --- src/invidious/routes/playlists.cr | 22 ++++++++++++++++++++++ src/invidious/views/add_playlist_items.ecr | 30 +----------------------------- src/invidious/views/edit_playlist.ecr | 25 +------------------------ src/invidious/views/playlist.ecr | 25 +------------------------ 4 files changed, 25 insertions(+), 77 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 1dd3f32e..604fe4e1 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -170,6 +170,13 @@ module Invidious::Routes::Playlists csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY) + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/playlist?list=#{playlist.id}", + current_page: page, + show_next: (videos.size == 100) + ) + templated "edit_playlist" end @@ -252,6 +259,14 @@ module Invidious::Routes::Playlists videos = [] of SearchVideo end + # Pagination + query_encoded = URI.encode_www_form(query.try &.text || "", space_to_plus: true) + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/add_playlist_items?list=#{playlist.id}&q=#{query_encoded}", + current_page: page, + show_next: (videos.size >= 20) + ) + env.set "add_playlist_items", plid templated "add_playlist_items" end @@ -427,6 +442,13 @@ module Invidious::Routes::Playlists env.set "remove_playlist_items", plid end + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/playlist?list=#{playlist.id}", + current_page: page, + show_next: (page_count != 1 && page < page_count) + ) + templated "playlist" end diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index bcba74cf..6aea82ae 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -31,33 +31,5 @@ -
    - <% videos.each_slice(4) do |slice| %> - <% slice.each do |item| %> - <%= rendered "components/item" %> - <% end %> - <% end %> -
    - - -<% if query %> - <%- query_encoded = URI.encode_www_form(query.text, space_to_plus: true) -%> -
    -
    - <% if query.page > 1 %> - - <%= translate(locale, "Previous page") %> - - <% end %> -
    -
    -
    - <% if videos.size >= 20 %> - - <%= translate(locale, "Next page") %> - - <% end %> -
    -
    -<% end %> +<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr index 548104c8..d2981886 100644 --- a/src/invidious/views/edit_playlist.ecr +++ b/src/invidious/views/edit_playlist.ecr @@ -56,28 +56,5 @@
    -
    -<% videos.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
    - - -
    -
    - <% if page > 1 %> - - <%= translate(locale, "Previous page") %> - - <% end %> -
    -
    -
    - <% if videos.size == 100 %> - - <%= translate(locale, "Next page") %> - - <% end %> -
    -
    +<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index a04acf4c..08995a83 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -100,28 +100,5 @@ <% end %> -
    -<% videos.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
    - - -
    -
    - <% if page > 1 %> - - <%= translate(locale, "Previous page") %> - - <% end %> -
    -
    -
    - <% if page_count != 1 && page < page_count %> - - <%= translate(locale, "Next page") %> - - <% end %> -
    -
    +<%= rendered "components/items_paginated" %> -- cgit v1.2.3 From efaf7cb09c8aad606d59cacab71c4a0a269d785b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 18 Apr 2023 00:12:56 +0200 Subject: HTML: Use the new pagination component for search results --- src/invidious/routes/search.cr | 22 ++++++++++++++-------- src/invidious/views/hashtag.ecr | 35 +---------------------------------- src/invidious/views/search.ecr | 35 +---------------------------------- 3 files changed, 16 insertions(+), 76 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 6c3088de..edf0351c 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -59,17 +59,21 @@ module Invidious::Routes::Search return error_template(500, ex) end - params = query.to_http_params - url_prev_page = "/search?#{params}&page=#{query.page - 1}" - url_next_page = "/search?#{params}&page=#{query.page + 1}" - redirect_url = Invidious::Frontend::Misc.redirect_url(env) + # Pagination + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/search?#{query.to_http_params}", + current_page: query.page, + show_next: (videos.size >= 20) + ) + if query.type == Invidious::Search::Query::Type::Channel env.set "search", "channel:#{query.channel} #{query.text}" else env.set "search", query.text end + templated "search" end end @@ -96,11 +100,13 @@ module Invidious::Routes::Search return error_template(500, ex) end - params = env.params.query.empty? ? "" : "&#{env.params.query}" - + # Pagination hashtag_encoded = URI.encode_www_form(hashtag, space_to_plus: false) - url_prev_page = "/hashtag/#{hashtag_encoded}?page=#{page - 1}#{params}" - url_next_page = "/hashtag/#{hashtag_encoded}?page=#{page + 1}#{params}" + page_nav_html = Frontend::Pagination.nav_numeric(locale, + base_url: "/hashtag/#{hashtag_encoded}", + current_page: page, + show_next: (videos.size >= 60) + ) templated "hashtag" end diff --git a/src/invidious/views/hashtag.ecr b/src/invidious/views/hashtag.ecr index 3351c21c..2000337e 100644 --- a/src/invidious/views/hashtag.ecr +++ b/src/invidious/views/hashtag.ecr @@ -4,38 +4,5 @@
    -
    -
    - <%- if page > 1 -%> - <%= translate(locale, "Previous page") %> - <%- end -%> -
    -
    -
    - <%- if videos.size >= 60 -%> - <%= translate(locale, "Next page") %> - <%- end -%> -
    -
    -
    - <%- videos.each do |item| -%> - <%= rendered "components/item" %> - <%- end -%> -
    - - - -
    -
    - <%- if page > 1 -%> - <%= translate(locale, "Previous page") %> - <%- end -%> -
    -
    -
    - <%- if videos.size >= 60 -%> - <%= translate(locale, "Next page") %> - <%- end -%> -
    -
    +<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index a7469e36..627a13b0 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -7,19 +7,6 @@ <%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
    -
    -
    - <%- if query.page > 1 -%> - <%= translate(locale, "Previous page") %> - <%- end -%> -
    -
    -
    - <%- if videos.size >= 20 -%> - <%= translate(locale, "Next page") %> - <%- end -%> -
    -
    <%- if videos.empty? -%>
    @@ -30,25 +17,5 @@
    <%- else -%> -
    - <%- videos.each do |item| -%> - <%= rendered "components/item" %> - <%- end -%> -
    + <%= rendered "components/items_paginated" %> <%- end -%> - - - -
    -
    - <%- if query.page > 1 -%> - <%= translate(locale, "Previous page") %> - <%- end -%> -
    -
    -
    - <%- if videos.size >= 20 -%> - <%= translate(locale, "Next page") %> - <%- end -%> -
    -
    -- cgit v1.2.3 From 7bd6d0ac4961e7f2433eb3268a45b78642229896 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 21 Apr 2023 00:28:11 +0200 Subject: HTML: Use the new pagination component for channel pages --- src/invidious/routes/playlists.cr | 14 ++++++------ src/invidious/routes/search.cr | 8 +++---- src/invidious/views/channel.ecr | 25 ++++++---------------- src/invidious/views/components/items_paginated.ecr | 2 +- src/invidious/views/search.ecr | 2 +- 5 files changed, 20 insertions(+), 31 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index 604fe4e1..5cb96809 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -163,9 +163,9 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(playlist, offset: (page - 1) * 100) + items = get_playlist_videos(playlist, offset: (page - 1) * 100) rescue ex - videos = [] of PlaylistVideo + items = [] of PlaylistVideo end csrf_token = generate_response(sid, {":edit_playlist"}, HMAC_KEY) @@ -174,7 +174,7 @@ module Invidious::Routes::Playlists page_nav_html = Frontend::Pagination.nav_numeric(locale, base_url: "/playlist?list=#{playlist.id}", current_page: page, - show_next: (videos.size == 100) + show_next: (items.size == 100) ) templated "edit_playlist" @@ -254,9 +254,9 @@ module Invidious::Routes::Playlists begin query = Invidious::Search::Query.new(env.params.query, :playlist, region) - videos = query.process.select(SearchVideo).map(&.as(SearchVideo)) + items = query.process.select(SearchVideo).map(&.as(SearchVideo)) rescue ex - videos = [] of SearchVideo + items = [] of SearchVideo end # Pagination @@ -264,7 +264,7 @@ module Invidious::Routes::Playlists page_nav_html = Frontend::Pagination.nav_numeric(locale, base_url: "/add_playlist_items?list=#{playlist.id}&q=#{query_encoded}", current_page: page, - show_next: (videos.size >= 20) + show_next: (items.size >= 20) ) env.set "add_playlist_items", plid @@ -433,7 +433,7 @@ module Invidious::Routes::Playlists end begin - videos = get_playlist_videos(playlist, offset: (page - 1) * 200) + items = get_playlist_videos(playlist, offset: (page - 1) * 200) rescue ex return error_template(500, "Error encountered while retrieving playlist videos.
    #{ex.message}") end diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index edf0351c..5be33533 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -52,7 +52,7 @@ module Invidious::Routes::Search user = env.get? "user" begin - videos = query.process + items = query.process rescue ex : ChannelSearchException return error_template(404, "Unable to find channel with id of '#{HTML.escape(ex.channel)}'. Are you sure that's an actual channel id? It should look like 'UC4QobU6STFB0P71PMvOGN5A'.") rescue ex @@ -65,7 +65,7 @@ module Invidious::Routes::Search page_nav_html = Frontend::Pagination.nav_numeric(locale, base_url: "/search?#{query.to_http_params}", current_page: query.page, - show_next: (videos.size >= 20) + show_next: (items.size >= 20) ) if query.type == Invidious::Search::Query::Type::Channel @@ -95,7 +95,7 @@ module Invidious::Routes::Search end begin - videos = Invidious::Hashtag.fetch(hashtag, page) + items = Invidious::Hashtag.fetch(hashtag, page) rescue ex return error_template(500, ex) end @@ -105,7 +105,7 @@ module Invidious::Routes::Search page_nav_html = Frontend::Pagination.nav_numeric(locale, base_url: "/hashtag/#{hashtag_encoded}", current_page: page, - show_next: (videos.size >= 60) + show_next: (items.size >= 60) ) templated "hashtag" diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 6e62a471..91fe40b9 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -15,7 +15,12 @@ youtube_url = "https://www.youtube.com#{relative_url}" redirect_url = Invidious::Frontend::Misc.redirect_url(env) --%> + + page_nav_html = IV::Frontend::Pagination.nav_ctoken(locale, + base_url: relative_url, + ctoken: next_continuation + ) +%> <% content_for "header" do %> <%- if selected_tab.videos? -%> @@ -43,21 +48,5 @@
    -
    -<% items.each do |item| %> - <%= rendered "components/item" %> -<% end %> -
    - - -
    -
    -
    - <% if next_continuation %> - - <%= translate(locale, "Next page") %> - - <% end %> -
    -
    +<%= rendered "components/items_paginated" %> diff --git a/src/invidious/views/components/items_paginated.ecr b/src/invidious/views/components/items_paginated.ecr index c82b1772..4534a0a3 100644 --- a/src/invidious/views/components/items_paginated.ecr +++ b/src/invidious/views/components/items_paginated.ecr @@ -1,7 +1,7 @@ <%= page_nav_html %>
    - <%- videos.each do |item| -%> + <%- items.each do |item| -%> <%= rendered "components/item" %> <%- end -%>
    diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index 627a13b0..b1300214 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -8,7 +8,7 @@
    -<%- if videos.empty? -%> +<%- if items.empty? -%>
    <%= translate(locale, "search_message_no_results") %>

    -- cgit v1.2.3 From b6bbfb9b200fc920854ce91835026da0fd6552db Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 22 Apr 2023 12:58:46 +0200 Subject: HTML: Use new buttons for thumbnail overlays In addition, this commit also heavily changes the structure of the generic "video card" item. Main benefits: * Improved accessibility for keyboard users * Many styling glitches were fixed * PlaylistVideos now use the same items as the rest * Elements all have distinct CSS classes * Design can be expanded to add more icons --- assets/css/default.css | 51 ++++++----- src/invidious/views/components/item.ecr | 149 +++++++++++++------------------- src/invidious/views/feeds/history.ecr | 8 +- 3 files changed, 94 insertions(+), 114 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index eb90c09c..48cb4264 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -152,9 +152,15 @@ body a.pure-button-primary:focus { color: #fff; } -button.pure-button-secondary:hover, -button.pure-button-secondary:focus { - border-color: rgba(0, 182, 240, 1); +.pure-button-secondary:hover, +.pure-button-secondary:focus { + color: rgb(0, 182, 240); + border-color: rgb(0, 182, 240); +} + +.pure-button-secondary.low-profile { + padding: 5px 10px; + margin: 0; } @@ -163,21 +169,19 @@ button.pure-button-secondary:focus { */ div.thumbnail { - padding: 28.125%; position: relative; + width: 100%; box-sizing: border-box; } img.thumbnail { - position: absolute; + display: block; /* See: https://stackoverflow.com/a/11635197 */ width: 100%; - height: 100%; - left: 0; - top: 0; object-fit: cover; } div.watched-overlay { + z-index: 50; position: absolute; top: 0; left: 0; @@ -195,28 +199,27 @@ div.watched-indicator { background-color: red; } -.length { +div.thumbnail > .top-left-overlay, +div.thumbnail > .bottom-right-overlay { z-index: 100; position: absolute; - background-color: rgba(35, 35, 35, 0.75); - color: #fff; - border-radius: 2px; - padding: 2px; + padding: 0; + margin: 0; font-size: 16px; - right: 0.25em; - bottom: -0.75em; } -.watched { - z-index: 100; - position: absolute; - background-color: rgba(35, 35, 35, 0.75); +.top-left-overlay { top: 0.6em; left: 0.6em; } +.bottom-right-overlay { bottom: 0.6em; right: 0.6em; } + +.length { + padding: 1px; + margin: -2px 0; color: #fff; - border-radius: 2px; - padding: 4px 8px 4px 8px; - font-size: 16px; - left: 0.2em; - top: -0.7em; + border-radius: 3px; +} + +.length, .top-left-overlay button { + background-color: rgba(35, 35, 35, 0.85); } diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 7cfd38db..f05e1338 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -7,7 +7,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - " alt="" /> + " alt="" />
    <% end %>

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    @@ -25,7 +25,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - " alt="" /> + " alt="" />

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -38,7 +38,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> @@ -54,104 +54,79 @@

    <%= HTML.escape(item.author) %>

    - <% when PlaylistVideo %> - - <% if !env.get("preferences").as(Preferences).thin_mode %> -
    - - - <% if plid_form = 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 %> -

    <%= recode_length_seconds(item.length_seconds) %>

    - <% end %> + <% when Category %> + <% else %> + <%- + # `endpoint_params` is used for the "video-context-buttons" component + if item.is_a?(PlaylistVideo) + link_url = "/watch?v=#{item.id}&list=#{item.plid}&index=#{item.index}" + endpoint_params = "?v=#{item.id}&list=#{item.plid}" + else + link_url = "/watch?v=#{item.id}" + endpoint_params = "?v=#{item.id}" + end + -%> - <% if item_watched %> -
    -
    - <% end %> -
    - <% end %> -

    <%= HTML.escape(item.title) %>

    -
    +
    + <%- if !env.get("preferences").as(Preferences).thin_mode -%> + + + + <%- end -%> -
    - - <% endpoint_params = "?v=#{item.id}&list=#{item.plid}" %> - <%= rendered "components/video-context-buttons" %> -
    +
    + <%- if env.get? "show_watched" -%> +
    " method="post"> + "> + + + <%- end -%> -
    -
    - <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %> -

    <%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>

    - <% elsif Time.utc - item.published > 1.minute %> -

    <%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>

    - <% end %> + <%- if plid_form = env.get?("add_playlist_items") -%> + <%- form_parameters = "action_add_video=1&video_id=#{item.id}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%> +
    + "> + + + <%- elsif item.is_a?(PlaylistVideo) && (plid_form = env.get?("remove_playlist_items")) -%> + <%- form_parameters = "action_remove_video=1&set_video_id=#{item.index}&playlist_id=#{plid_form}&referer=#{env.get("current_page")}" -%> +
    + "> + + + <%- end -%>
    - <% if item.responds_to?(:views) && item.views %> -
    -

    <%= translate_count(locale, "generic_views_count", item.views || 0, NumberFormatting::Short) %>

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

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

    + <%- elsif item.length_seconds != 0 -%> +

    <%= recode_length_seconds(item.length_seconds) %>

    + <%- end -%>
    + + <% if item_watched %> +
    +
    <% end %>
    - <% when Category %> - <% else %> - - <% if !env.get("preferences").as(Preferences).thin_mode %> -
    - - <% if env.get? "show_watched" %> -
    " method="post"> - "> -

    - -

    - - <% elsif plid_form = env.get? "add_playlist_items" %> -
    " method="post"> - "> -

    - -

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

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

    - <% elsif item.length_seconds != 0 %> -

    <%= recode_length_seconds(item.length_seconds) %>

    - <% end %> - - <% if item_watched %> -
    -
    - <% end %> -
    - <% end %> -

    <%= HTML.escape(item.title) %>

    -
    + diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr index 2234b297..5301a232 100644 --- a/src/invidious/views/feeds/history.ecr +++ b/src/invidious/views/feeds/history.ecr @@ -35,12 +35,14 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    + +
    " method="post"> "> -

    - -

    + +

    <% end %> -- cgit v1.2.3 From 080c7446c6c26c5d8670107cf4161ba4609e5e4a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 22 Apr 2023 17:50:34 +0200 Subject: HTML: Use new buttons for playlists (save/delete/add videos/etc...) --- assets/css/default.css | 2 +- locales/en-US.json | 6 +++ locales/fr.json | 6 +++ src/invidious/views/components/item.ecr | 10 ++--- src/invidious/views/edit_playlist.ecr | 64 +++++++++++++++--------------- src/invidious/views/playlist.ecr | 70 +++++++++++++++++++++------------ 6 files changed, 94 insertions(+), 64 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 48cb4264..7a99a0db 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -219,7 +219,7 @@ div.thumbnail > .bottom-right-overlay { } .length, .top-left-overlay button { - background-color: rgba(35, 35, 35, 0.85); + background-color: rgba(35, 35, 35, 0.85) !important; } diff --git a/locales/en-US.json b/locales/en-US.json index e13ba968..c41a631a 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -9,6 +9,11 @@ "generic_subscribers_count_plural": "{{count}} subscribers", "generic_subscriptions_count": "{{count}} subscription", "generic_subscriptions_count_plural": "{{count}} subscriptions", + "generic_button_delete": "Delete", + "generic_button_edit": "Edit", + "generic_button_save": "Save", + "generic_button_cancel": "Cancel", + "generic_button_rss": "RSS", "LIVE": "LIVE", "Shared `x` ago": "Shared `x` ago", "Unsubscribe": "Unsubscribe", @@ -170,6 +175,7 @@ "Title": "Title", "Playlist privacy": "Playlist privacy", "Editing playlist `x`": "Editing playlist `x`", + "playlist_button_add_items": "Add videos", "Show more": "Show more", "Show less": "Show less", "Watch on YouTube": "Watch on YouTube", diff --git a/locales/fr.json b/locales/fr.json index d2607a49..2eb4dd2b 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -9,6 +9,11 @@ "generic_subscribers_count_plural": "{{count}} abonnés", "generic_subscriptions_count": "{{count}} abonnement", "generic_subscriptions_count_plural": "{{count}} abonnements", + "generic_button_delete": "Supprimer", + "generic_button_edit": "Editer", + "generic_button_save": "Enregistrer", + "generic_button_cancel": "Annuler", + "generic_button_rss": "RSS", "LIVE": "EN DIRECT", "Shared `x` ago": "Ajoutée il y a `x`", "Unsubscribe": "Se désabonner", @@ -149,6 +154,7 @@ "Title": "Titre", "Playlist privacy": "Paramètres de confidentialité de la liste de lecture", "Editing playlist `x`": "Modifier la liste de lecture `x`", + "playlist_button_add_items": "Ajouter des vidéos", "Show more": "Afficher plus", "Show less": "Afficher moins", "Watch on YouTube": "Voir la vidéo sur Youtube", diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index f05e1338..decdcb2f 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -71,6 +71,11 @@ <%- if !env.get("preferences").as(Preferences).thin_mode -%> + + <% if item_watched %> +
    +
    + <% end %>
    <%- end -%> @@ -109,11 +114,6 @@

    <%= recode_length_seconds(item.length_seconds) %>

    <%- end -%>
    - - <% if item_watched %> -
    -
    - <% end %>
    diff --git a/src/invidious/views/edit_playlist.ecr b/src/invidious/views/edit_playlist.ecr index d2981886..34157c67 100644 --- a/src/invidious/views/edit_playlist.ecr +++ b/src/invidious/views/edit_playlist.ecr @@ -6,35 +6,43 @@ <% end %>
    -
    -
    +
    + +
    + +
    +

    +
    +
    + +
    +
    <%= HTML.escape(playlist.author) %> | <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> | - <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> | - "> - -
    -
    -

    -
    - -
    -
    -
    -

    +
    @@ -44,14 +52,6 @@ -<% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> -
    -

    - -

    -
    -<% end %> -

    diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 08995a83..8d4d116d 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -7,8 +7,51 @@ <% end %>
    -
    +

    <%= title %>

    +
    + +
    + <%- if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email -%> + + + + <%- else -%> +
    + <%- if IV::Database::Playlists.exists?(playlist.id) -%> + +  <%= translate(locale, "Subscribe") %> + + <%- else -%> + +  <%= translate(locale, "Unsubscribe") %> + + <%- end -%> +
    + <%- end -%> + + +
    +
    + +
    +
    <% if playlist.is_a? InvidiousPlaylist %> <% if playlist.author == user.try &.email %> @@ -54,37 +97,12 @@
    <% end %>
    -
    -

    -
    - <% if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email %> -
    -
    - <% else %> - <% if Invidious::Database::Playlists.exists?(playlist.id) %> -
    - <% else %> -
    - <% end %> - <% end %> -
    -
    -

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

    - -

    -
    -<% end %> -

    -- cgit v1.2.3 From 43dcab225caca7034346a79da340e434cdb4d407 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 22 Apr 2023 19:45:45 +0200 Subject: HTML: merge MixVideo with other types in item.ecr --- src/invidious/views/components/item.ecr | 25 ++++--------------------- 1 file changed, 4 insertions(+), 21 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index decdcb2f..0fa9c807 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -34,26 +34,6 @@

    <%= HTML.escape(item.author) %><% if !item.is_a?(InvidiousPlaylist) && !item.author_verified.nil? && item.author_verified %> <% end %>

    - <% when MixVideo %> - - <% if !env.get("preferences").as(Preferences).thin_mode %> -
    - - <% if item.length_seconds != 0 %> -

    <%= recode_length_seconds(item.length_seconds) %>

    - <% end %> - - <% if item_watched %> -
    -
    - <% end %> -
    - <% end %> -

    <%= HTML.escape(item.title) %>

    -
    - -

    <%= HTML.escape(item.author) %>

    -
    <% when Category %> <% else %> <%- @@ -61,6 +41,9 @@ if item.is_a?(PlaylistVideo) link_url = "/watch?v=#{item.id}&list=#{item.plid}&index=#{item.index}" endpoint_params = "?v=#{item.id}&list=#{item.plid}" + elsif item.is_a?(MixVideo) + link_url = "/watch?v=#{item.id}&list=#{item.rdid}" + endpoint_params = "?v=#{item.id}&list=#{item.rdid}" else link_url = "/watch?v=#{item.id}" endpoint_params = "?v=#{item.id}" @@ -134,7 +117,7 @@
    <% if item.responds_to?(:premiere_timestamp) && item.premiere_timestamp.try &.> Time.utc %>

    <%= translate(locale, "Premieres in `x`", recode_date((item.premiere_timestamp.as(Time) - Time.utc).ago, locale)) %>

    - <% elsif Time.utc - item.published > 1.minute %> + <% elsif item.responds_to?(:published) && (Time.utc - item.published) > 1.minute %>

    <%= translate(locale, "Shared `x` ago", recode_date(item.published, locale)) %>

    <% end %>
    -- cgit v1.2.3 From 8718f2068859b12174cecf4af11c30bfe64103a6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 22 Apr 2023 19:59:01 +0200 Subject: HTML: Fix thin mode/thumbnail on other items --- src/invidious/views/components/item.ecr | 71 ++++++++++++++++++++++----------- src/invidious/views/feeds/history.ecr | 28 ++++++------- 2 files changed, 61 insertions(+), 38 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 0fa9c807..9b73f7ee 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -1,39 +1,64 @@ -<% item_watched = !item.is_a?(SearchChannel | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil %> +<%- + thin_mode = env.get("preferences").as(Preferences).thin_mode + item_watched = !item.is_a?(SearchChannel | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil + author_verified = item.responds_to?(:author_verified) && item.author_verified +-%>
    <% case item when %> <% when SearchChannel %> - - <% if !env.get("preferences").as(Preferences).thin_mode %> + <% if !thin_mode %> +
    " alt="" />
    - <% end %> -

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    -
    + + <% end %> + + +

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %>
    <%= item.description_html %>
    <% when SearchPlaylist, InvidiousPlaylist %> - <% if item.id.starts_with? "RD" %> - <% url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").request_target.split("/")[2]}" %> - <% else %> - <% url = "/playlist?list=#{item.id}" %> - <% end %> + <%- + if item.id.starts_with? "RD" + link_url = "/mix?list=#{item.id}&continuation=#{URI.parse(item.thumbnail || "/vi/-----------").request_target.split("/")[2]}" + else + link_url = "/playlist?list=#{item.id}" + end + -%> - - <% if !env.get("preferences").as(Preferences).thin_mode %> - + + + + <% when Category %> <% else %> <%- @@ -106,7 +131,7 @@
    diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr index 5301a232..83ea7238 100644 --- a/src/invidious/views/feeds/history.ecr +++ b/src/invidious/views/feeds/history.ecr @@ -31,22 +31,20 @@ <% watched.each do |item| %>
    - - <% if !env.get("preferences").as(Preferences).thin_mode %> -
    - +
    + + + -
    -
    " method="post"> - "> - - -
    -
    -

    - <% end %> - +
    +
    " method="post"> + "> + + +
    +
    +

    <% end %> -- cgit v1.2.3 From 42fa6ad2a30038cd7cdc705f5da2bffdc9714349 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 24 Apr 2023 19:44:15 +0200 Subject: HTML/CSS: Fix buttons' responsiveness --- assets/css/default.css | 94 ++++++++++++++++------ .../views/components/video-context-buttons.ecr | 4 +- src/invidious/views/playlist.ecr | 18 ++--- 3 files changed, 78 insertions(+), 38 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index f671c3bf..21121f4d 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -1,3 +1,7 @@ +/* + * Common attributes + */ + html, body { font-family: BlinkMacSystemFont, -apple-system, "Segoe UI", Roboto, Oxygen, @@ -11,6 +15,16 @@ body { min-height: 100vh; } +.h-box { + padding-left: 1em; + padding-right: 1em; +} + +.v-box { + padding-top: 1em; + padding-bottom: 1em; +} + .deleted { background-color: rgb(255, 0, 0, 0.5); } @@ -20,6 +34,34 @@ body { margin-bottom: 20px; } +.title { + margin: 0.5em 0 1em 0; +} + +/* A flex container */ +.flexible { + display: flex; + align-items: center; +} + +.flex-left { + display: flex; + flex: 1 1 auto; + flex-flow: row wrap; + justify-content: flex-start; +} +.flex-right { + display: flex; + flex: 2 0 auto; + flex-flow: row nowrap; + justify-content: flex-end; +} + + +/* + * Channel page + */ + .channel-profile > * { font-size: 1.17em; font-weight: bold; @@ -90,16 +132,6 @@ body a.channel-owner { } } -.h-box { - padding-left: 1em; - padding-right: 1em; -} - -.v-box { - padding-top: 1em; - padding-bottom: 1em; -} - div { overflow-wrap: break-word; word-wrap: break-word; @@ -144,9 +176,15 @@ body a.pure-button-primary, margin: 0; } +/* Has to be combined with flex-left/right */ +.button-container { + flex-flow: wrap; + gap: 0.5em 0.75em; +} + /* - * Thumbnails + * Video thumbnails */ div.thumbnail { @@ -280,6 +318,11 @@ input[type="search"]::-webkit-search-cancel-button { margin-right: 1em; } + +/* + * Responsive rules + */ + @media only screen and (max-aspect-ratio: 16/9) { .player-dimensions.vjs-fluid { padding-top: 46.86% !important; @@ -298,20 +341,28 @@ input[type="search"]::-webkit-search-cancel-button { .navbar > div { display: flex; justify-content: center; - } - - .navbar > div:not(:last-child) { - margin-bottom: 1em; + margin-bottom: 25px; } .navbar > .searchbar > form { - width: 60%; + width: 75%; } h1 { font-size: 1.25em; margin: 0.42em 0; } + + /* Space out the subscribe & RSS buttons and align them to the left */ + .title.flexible { display: block; } + .title.flexible > .flex-right { margin: 0.75em 0; justify-content: flex-start; } + + /* Space out buttons to make them easier to tap */ + .user-field { font-size: 125%; } + .user-field > :not(:last-child) { margin-right: 1.75em; } + + .icon-buttons { font-size: 125%; } + .icon-buttons > :not(:last-child) { margin-right: 0.75em; } } @media screen and (max-width: 320px) { @@ -328,10 +379,6 @@ input[type="search"]::-webkit-search-cancel-button { .video-card-row { margin: 15px 0; } -.flexible { display: flex; } -.flex-left { flex: 1 1 100%; flex-wrap: wrap; } -.flex-right { flex: 1 0 auto; flex-wrap: nowrap; } - p.channel-name { margin: 0; } p.video-data { margin: 0; font-weight: bold; font-size: 80%; } @@ -659,12 +706,7 @@ label[for="music-desc-expansion"]:hover { } /* Bidi (bidirectional text) support */ -h1, -h2, -h3, -h4, -h5, -p, +h1, h2, h3, h4, h5, p, #descriptionWrapper, #description-box, #music-description-box { diff --git a/src/invidious/views/components/video-context-buttons.ecr b/src/invidious/views/components/video-context-buttons.ecr index ddb6c983..385ed6b3 100644 --- a/src/invidious/views/components/video-context-buttons.ecr +++ b/src/invidious/views/components/video-context-buttons.ecr @@ -1,4 +1,4 @@ -
    +
    " href="https://www.youtube.com/watch<%=endpoint_params%>"> @@ -6,7 +6,7 @@ " href="/watch<%=endpoint_params%>&listen=1"> - + <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> " href="/redirect?referer=%2Fwatch<%=URI.encode_www_form(endpoint_params)%>"> diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 8d4d116d..ee9ba87b 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -6,30 +6,28 @@ <% end %> -
    -
    -

    <%= title %>

    -
    +
    +

    <%= title %>

    -
    +
    <%- if playlist.is_a?(InvidiousPlaylist) && playlist.author == user.try &.email -%> -
    + -
    + -
    + <%- else -%> -
    +
    <%- if IV::Database::Playlists.exists?(playlist.id) -%>  <%= translate(locale, "Subscribe") %> @@ -42,7 +40,7 @@
    <%- end -%> -
    +
     <%= translate(locale, "generic_button_rss") %> -- cgit v1.2.3 From 411208bbd211d7effe278eabe23d5e2f502b5ea6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 24 Apr 2023 20:28:40 +0200 Subject: HTML: Reorder buttons on the channel and watch pages --- src/invidious/views/components/channel_info.ecr | 27 ++++++++++--------- .../views/components/subscribe_widget.ecr | 6 ----- src/invidious/views/watch.ecr | 31 ++++++++++++++-------- 3 files changed, 34 insertions(+), 30 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/channel_info.ecr b/src/invidious/views/components/channel_info.ecr index 59888760..f4164f31 100644 --- a/src/invidious/views/components/channel_info.ecr +++ b/src/invidious/views/components/channel_info.ecr @@ -8,29 +8,30 @@
    <% end %> -
    -
    +
    +
    <%= author %><% if !channel.verified.nil? && channel.verified %> <% end %>
    -
    -

    - -

    -
    -
    -
    -
    -

    <%= channel.description_html %>

    +
    +
    + <% sub_count_text = number_to_short_text(channel.sub_count) %> + <%= rendered "components/subscribe_widget" %> +
    + +
    - <% sub_count_text = number_to_short_text(channel.sub_count) %> - <%= rendered "components/subscribe_widget" %> +

    <%= channel.description_html %>

    diff --git a/src/invidious/views/components/subscribe_widget.ecr b/src/invidious/views/components/subscribe_widget.ecr index b9d5f783..05e4e253 100644 --- a/src/invidious/views/components/subscribe_widget.ecr +++ b/src/invidious/views/components/subscribe_widget.ecr @@ -1,22 +1,18 @@ <% if user %> <% if subscriptions.includes? ucid %> -

    " method="post"> "> -

    <% else %> -

    " method="post"> "> -

    <% end %> <% else %> -

    "> <%= translate(locale, "Subscribe") %> | <%= sub_count_text %> -

    <% end %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 5b3190f3..4f4354a9 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -204,19 +204,28 @@ we're going to need to do it here in order to allow for translations.
    -
    - -
    - <% if !video.author_thumbnail.empty? %> - - <% end %> - <%= author %><% if !video.author_verified.nil? && video.author_verified %> <% end %> -
    -
    - <% sub_count_text = video.sub_count_text %> - <%= rendered "components/subscribe_widget" %> +
    + +
    +
    + <% sub_count_text = video.sub_count_text %> + <%= rendered "components/subscribe_widget" %> +
    +
    +
    + +

    <% if video.premiere_timestamp.try &.> Time.utc %> <%= video.premiere_timestamp.try { |t| translate(locale, "Premieres `x`", t.to_s("%B %-d, %R UTC")) } %> -- cgit v1.2.3 From 06b2bab795ebf54e9c6a396e37a129a87d39675a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 24 Apr 2023 22:19:46 +0200 Subject: HTML: Fix thumbnails of related videos (watch page) --- src/invidious/views/watch.ecr | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 4f4354a9..9275631c 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -304,15 +304,26 @@ we're going to need to do it here in order to allow for translations. <% video.related_videos.each do |rv| %> <% if rv["id"]? %> - &listen=<%= params.listen %>"> - <% if !env.get("preferences").as(Preferences).thin_mode %> -

    +
    + + - <% end %> -

    <%= rv["title"] %>

    -
    + + <%- end -%> + +
    + <%- if (length_seconds = rv["length_seconds"]?.try &.to_i?) && length_seconds != 0 -%> +

    <%= recode_length_seconds(length_seconds) %>

    + <%- end -%> +
    +
    + + +
    <% if rv["ucid"]? %> @@ -330,6 +341,8 @@ we're going to need to do it here in order to allow for translations. %>
    + +
    <% end %> <% end %>
    -- cgit v1.2.3 From c17404890ca9618ebc828a06bc88ff2bd79e811e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 23 May 2023 22:49:44 +0200 Subject: HTML: Use the new pagination component for history/subscriptions --- src/invidious/routes/feeds.cr | 8 ++++++++ src/invidious/views/feeds/history.ecr | 24 +++++++----------------- src/invidious/views/feeds/subscriptions.ecr | 25 ++++++++----------------- 3 files changed, 23 insertions(+), 34 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index fc62c5a3..a8246b2e 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -102,6 +102,10 @@ module Invidious::Routes::Feeds end env.set "user", user + # Used for pagination links + base_url = "/feed/subscriptions" + base_url += "?max_results=#{max_results}" if env.params.query.has_key?("max_results") + templated "feeds/subscriptions" end @@ -129,6 +133,10 @@ module Invidious::Routes::Feeds end watched ||= [] of String + # Used for pagination links + base_url = "/feed/history" + base_url += "?max_results=#{max_results}" if env.params.query.has_key?("max_results") + templated "feeds/history" end diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr index 83ea7238..bda4e1f3 100644 --- a/src/invidious/views/feeds/history.ecr +++ b/src/invidious/views/feeds/history.ecr @@ -50,20 +50,10 @@ <% end %>
    - +<%= + IV::Frontend::Pagination.nav_numeric(locale, + base_url: base_url, + current_page: page, + show_next: (watched.size >= max_results) + ) +%> diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr index 9c69c5b0..c36bd00f 100644 --- a/src/invidious/views/feeds/subscriptions.ecr +++ b/src/invidious/views/feeds/subscriptions.ecr @@ -56,6 +56,7 @@ +
    <% videos.each do |item| %> <%= rendered "components/item" %> @@ -64,20 +65,10 @@ -
    - -
    -
    - <% if (videos.size + notifications.size) == max_results %> - &max_results=<%= max_results %><% end %>"> - <%= translate(locale, "Next page") %> - - <% end %> -
    -
    +<%= + IV::Frontend::Pagination.nav_numeric(locale, + base_url: base_url, + current_page: page, + show_next: ((videos.size + notifications.size) == max_results) + ) +%> -- cgit v1.2.3 From 9b75f79fb553403d0af7b2f9a1212a1e93bcf85b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 8 Jul 2023 21:17:44 +0200 Subject: HTML/CSS: Add thumbnail placeholder in thin mode This change is required to make the overlay buttons functional (add to and delete from playlist, mark as watched, etc.) --- assets/css/default.css | 5 +++++ src/invidious/views/components/item.ecr | 8 +++++++- src/invidious/views/watch.ecr | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 21121f4d..c31b24e5 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -199,6 +199,11 @@ img.thumbnail { object-fit: cover; } +.thumbnail-placeholder { + min-height: 50px; + border: 2px dotted; +} + div.watched-overlay { z-index: 50; position: absolute; diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 9b73f7ee..7ffd2d93 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -14,6 +14,8 @@ " alt="" /> + <%- else -%> +
    <% end %>
    @@ -41,6 +43,8 @@ " alt="" /> + <%- else -%> +
    <%- end -%>
    @@ -76,7 +80,7 @@ -%>
    - <%- if !env.get("preferences").as(Preferences).thin_mode -%> + <%- if !thin_mode -%> @@ -85,6 +89,8 @@
    <% end %>
    + <%- else -%> +
    <%- end -%>
    diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 9275631c..498d57a1 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -311,6 +311,8 @@ we're going to need to do it here in order to allow for translations. &listen=<%= params.listen %>"> /mqdefault.jpg" alt="" /> + <%- else -%> +
    <%- end -%>
    -- cgit v1.2.3 From 0110f865c39fd0a1d416502422110430f92f4ef3 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Sat, 8 Jul 2023 16:51:19 -0400 Subject: Playlist import no refresh --- src/invidious/user/imports.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 0a2fe1e2..86d0ce6e 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -133,7 +133,7 @@ struct Invidious::User next if !video_id begin - video = get_video(video_id) + video = get_video(video_id, false) rescue ex next end -- cgit v1.2.3 From f2fa3da9d2f8ffc1684997526ddd5b3357d88897 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Wed, 12 Jul 2023 11:06:34 -0700 Subject: Add support for releases and podcasts tabs --- locales/en-US.json | 2 ++ src/invidious/channels/playlists.cr | 18 ++++++++++ src/invidious/frontend/channel_page.cr | 2 ++ src/invidious/routes/api/v1/channels.cr | 62 +++++++++++++++++++++++++++++++-- src/invidious/routes/channels.cr | 44 +++++++++++++++++++++-- src/invidious/routing.cr | 5 +++ src/invidious/views/channel.ecr | 2 ++ src/invidious/yt_backend/extractors.cr | 5 +-- 8 files changed, 134 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index e13ba968..29dd7a40 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -474,6 +474,8 @@ "channel_tab_videos_label": "Videos", "channel_tab_shorts_label": "Shorts", "channel_tab_streams_label": "Livestreams", + "channel_tab_podcasts_label": "Podcasts", + "channel_tab_releases_label": "Releases", "channel_tab_playlists_label": "Playlists", "channel_tab_community_label": "Community", "channel_tab_channels_label": "Channels" diff --git a/src/invidious/channels/playlists.cr b/src/invidious/channels/playlists.cr index 8dc824b2..91029fe3 100644 --- a/src/invidious/channels/playlists.cr +++ b/src/invidious/channels/playlists.cr @@ -26,3 +26,21 @@ def fetch_channel_playlists(ucid, author, continuation, sort_by) return extract_items(initial_data, author, ucid) end + +def fetch_channel_podcasts(ucid, author, continuation) + if continuation + initial_data = YoutubeAPI.browse(continuation) + else + initial_data = YoutubeAPI.browse(ucid, params: "Eghwb2RjYXN0c_IGBQoDugEA") + end + return extract_items(initial_data, author, ucid) +end + +def fetch_channel_releases(ucid, author, continuation) + if continuation + initial_data = YoutubeAPI.browse(continuation) + else + initial_data = YoutubeAPI.browse(ucid, params: "EghyZWxlYXNlc_IGBQoDsgEA") + end + return extract_items(initial_data, author, ucid) +end diff --git a/src/invidious/frontend/channel_page.cr b/src/invidious/frontend/channel_page.cr index 53745dd5..fe7d6d6e 100644 --- a/src/invidious/frontend/channel_page.cr +++ b/src/invidious/frontend/channel_page.cr @@ -5,6 +5,8 @@ module Invidious::Frontend::ChannelPage Videos Shorts Streams + Podcasts + Releases Playlists Community Channels diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index bcb4db2c..adf05d30 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -245,7 +245,7 @@ module Invidious::Routes::API::V1::Channels channel = nil # Make the compiler happy get_channel() - items, continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) + items, next_continuation = fetch_channel_playlists(channel.ucid, channel.author, continuation, sort_by) JSON.build do |json| json.object do @@ -257,7 +257,65 @@ module Invidious::Routes::API::V1::Channels end end - json.field "continuation", continuation + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.podcasts(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + items, next_continuation = fetch_channel_podcasts(channel.ucid, channel.author, continuation) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", next_continuation if next_continuation + end + end + end + + def self.releases(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + ucid = env.params.url["ucid"] + continuation = env.params.query["continuation"]? + + # Use the macro defined above + channel = nil # Make the compiler happy + get_channel() + + items, next_continuation = fetch_channel_releases(channel.ucid, channel.author, continuation) + + JSON.build do |json| + json.object do + json.field "playlists" do + json.array do + items.each do |item| + item.to_json(locale, json) if item.is_a?(SearchPlaylist) + end + end + end + + json.field "continuation", next_continuation if next_continuation end end end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 16621994..9892ae2a 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -27,7 +27,7 @@ module Invidious::Routes::Channels item.author end end - items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) + items = items.select(SearchPlaylist) items.each(&.author = "") else sort_options = {"newest", "oldest", "popular"} @@ -105,13 +105,53 @@ module Invidious::Routes::Channels channel.ucid, channel.author, continuation, (sort_by || "last") ) - items = items.select(SearchPlaylist).map(&.as(SearchPlaylist)) + items = items.select(SearchPlaylist) items.each(&.author = "") selected_tab = Frontend::ChannelPage::TabsAvailable::Playlists templated "channel" end + def self.podcasts(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + sort_by = "" + sort_options = [] of String + + items, next_continuation = fetch_channel_podcasts( + channel.ucid, channel.author, continuation + ) + + items = items.select(SearchPlaylist) + items.each(&.author = "") + + selected_tab = Frontend::ChannelPage::TabsAvailable::Podcasts + templated "channel" + end + + def self.releases(env) + data = self.fetch_basic_information(env) + return data if !data.is_a?(Tuple) + + locale, user, subscriptions, continuation, ucid, channel = data + + sort_by = "" + sort_options = [] of String + + items, next_continuation = fetch_channel_releases( + channel.ucid, channel.author, continuation + ) + + items = items.select(SearchPlaylist) + items.each(&.author = "") + + selected_tab = Frontend::ChannelPage::TabsAvailable::Releases + templated "channel" + end + def self.community(env) data = self.fetch_basic_information(env) if !data.is_a?(Tuple) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index daaf4d88..9c43171c 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -118,6 +118,8 @@ module Invidious::Routing get "/channel/:ucid/videos", Routes::Channels, :videos get "/channel/:ucid/shorts", Routes::Channels, :shorts get "/channel/:ucid/streams", Routes::Channels, :streams + get "/channel/:ucid/podcasts", Routes::Channels, :podcasts + get "/channel/:ucid/releases", Routes::Channels, :releases get "/channel/:ucid/playlists", Routes::Channels, :playlists get "/channel/:ucid/community", Routes::Channels, :community get "/channel/:ucid/channels", Routes::Channels, :channels @@ -228,6 +230,9 @@ module Invidious::Routing get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams + get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts + get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases + get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels {% for route in {"videos", "latest", "playlists", "community", "search"} %} diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 6e62a471..066e25b5 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -9,6 +9,8 @@ when .streams? then "/channel/#{ucid}/streams" when .playlists? then "/channel/#{ucid}/playlists" when .channels? then "/channel/#{ucid}/channels" + when .podcasts? then "/channel/#{ucid}/podcasts" + when .releases? then "/channel/#{ucid}/releases" else "/channel/#{ucid}" end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 6686e6e7..e5029dc5 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -408,8 +408,8 @@ private module Parsers # Returns nil when the given object isn't a RichItemRenderer # # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used - # by the result page for hashtags. It is located inside a continuationItems - # container. + # by the result page for hashtags and for the podcast tab on channels. + # It is located inside a continuationItems container for hashtags. # module RichItemRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) @@ -421,6 +421,7 @@ private module Parsers private def self.parse(item_contents, author_fallback) child = VideoRendererParser.process(item_contents, author_fallback) child ||= ReelItemRendererParser.process(item_contents, author_fallback) + child ||= PlaylistRendererParser.process(item_contents, author_fallback) return child end -- cgit v1.2.3 From 05cc5033910cabe7008832e8917b93ee3112a540 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 15 Jul 2023 12:57:26 +0000 Subject: Fix lint --- src/invidious/views/channel.ecr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 066e25b5..4b50e7a0 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -9,8 +9,8 @@ when .streams? then "/channel/#{ucid}/streams" when .playlists? then "/channel/#{ucid}/playlists" when .channels? then "/channel/#{ucid}/channels" - when .podcasts? then "/channel/#{ucid}/podcasts" - when .releases? then "/channel/#{ucid}/releases" + when .podcasts? then "/channel/#{ucid}/podcasts" + when .releases? then "/channel/#{ucid}/releases" else "/channel/#{ucid}" end -- cgit v1.2.3 From 70145cba31fb7fa14dafa3493c9133c01f642116 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 11 Jul 2023 20:49:36 -0700 Subject: Community: Parse `Quiz` attachments --- src/invidious/channels/community.cr | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index aac4bc8a..671f6dee 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -216,6 +216,22 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) parse_item(attachment) .as(SearchPlaylist) .to_json(locale, json) + when .has_key?("quizRenderer") + json.object do + attachment = attachment["quizRenderer"] + json.field "type", "quiz" + json.field "totalVotes", short_text_to_number(attachment["totalVotes"]["simpleText"].as_s.split(" ")[0]) + json.field "choices" do + json.array do + attachment["choices"].as_a.each do |choice| + json.object do + json.field "text", choice.dig("text", "runs", 0, "text").as_s + json.field "isCorrect", choice["isCorrect"].as_bool + end + end + end + end + end else json.object do json.field "type", "unknown" -- cgit v1.2.3 From 839e90aeff93a18d59cb4fc53eb25cc5c152b44a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 15 Jul 2023 15:41:04 +0200 Subject: Extractors: Add module for 'hashtagTileRenderer' --- src/invidious/helpers/serialized_yt_data.cr | 21 +++++++++++- src/invidious/yt_backend/extractors.cr | 53 ++++++++++++++++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 7c12ad0e..e0bd7279 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -232,6 +232,25 @@ struct SearchChannel end end +struct SearchHashtag + include DB::Serializable + + property title : String + property url : String + property video_count : Int64 + property channel_count : Int64 + + def to_json(locale : String?, json : JSON::Builder) + json.object do + json.field "type", "hashtag" + json.field "title", self.title + json.field "url", self.url + json.field "videoCount", self.video_count + json.field "channelCount", self.channel_count + end + end +end + class Category include DB::Serializable @@ -274,4 +293,4 @@ struct Continuation end end -alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | Category +alias SearchItem = SearchVideo | SearchChannel | SearchPlaylist | SearchHashtag | Category diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index e5029dc5..8456313b 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -11,15 +11,16 @@ private ITEM_CONTAINER_EXTRACTOR = { } private ITEM_PARSERS = { + Parsers::RichItemRendererParser, Parsers::VideoRendererParser, Parsers::ChannelRendererParser, Parsers::GridPlaylistRendererParser, Parsers::PlaylistRendererParser, Parsers::CategoryRendererParser, - Parsers::RichItemRendererParser, Parsers::ReelItemRendererParser, Parsers::ItemSectionRendererParser, Parsers::ContinuationItemRendererParser, + Parsers::HashtagRendererParser, } private alias InitialData = Hash(String, JSON::Any) @@ -210,6 +211,56 @@ private module Parsers end end + # Parses an Innertube `hashtagTileRenderer` into a `SearchHashtag`. + # Returns `nil` when the given object is not a `hashtagTileRenderer`. + # + # A `hashtagTileRenderer` is a kind of search result. + # It can be found when searching for any hashtag (e.g "#hi" or "#shorts") + module HashtagRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["hashtagTileRenderer"]? + return self.parse(item_contents) + end + end + + private def self.parse(item_contents) + title = extract_text(item_contents["hashtag"]).not_nil! # E.g "#hi" + + # E.g "/hashtag/hi" + url = item_contents.dig?("onTapCommand", "commandMetadata", "webCommandMetadata", "url").try &.as_s + url ||= URI.encode_path("/hashtag/#{title.lchop('#')}") + + video_count_txt = extract_text(item_contents["hashtagVideoCount"]?) # E.g "203K videos" + channel_count_txt = extract_text(item_contents["hashtagChannelCount"]?) # E.g "81K channels" + + # Fallback for video/channel counts + if channel_count_txt.nil? || video_count_txt.nil? + # E.g: "203K videos • 81K channels" + info_text = extract_text(item_contents["hashtagInfoText"]?).try &.split(" • ") + + if info_text && info_text.size == 2 + video_count_txt ||= info_text[0] + channel_count_txt ||= info_text[1] + end + end + + return SearchHashtag.new({ + title: title, + url: url, + video_count: short_text_to_number(video_count_txt || ""), + channel_count: short_text_to_number(channel_count_txt || ""), + }) + rescue ex + LOGGER.debug("HashtagRendererParser: Failed to extract renderer.") + LOGGER.debug("HashtagRendererParser: Got exception: #{ex.message}") + return nil + end + + def self.parser_name + return {{@type.name}} + end + end + # Parses a InnerTube gridPlaylistRenderer into a SearchPlaylist. Returns nil when the given object isn't a gridPlaylistRenderer # # A gridPlaylistRenderer renders a playlist, that is located in a grid, to click on within the YouTube and Invidious UI. -- cgit v1.2.3 From f38d1f33b140a1de13e20d14b7a1ff0fcf0a40b4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 15 Jul 2023 15:42:46 +0200 Subject: HTML: Add UI element for 'SearchHashtag' in item.ecr --- src/invidious/views/components/item.ecr | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 7ffd2d93..c29ec47b 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -1,6 +1,6 @@ <%- thin_mode = env.get("preferences").as(Preferences).thin_mode - item_watched = !item.is_a?(SearchChannel | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil + item_watched = !item.is_a?(SearchChannel | SearchHashtag | SearchPlaylist | InvidiousPlaylist | Category) && env.get?("user").try &.as(User).watched.index(item.id) != nil author_verified = item.responds_to?(:author_verified) && item.author_verified -%> @@ -29,6 +29,30 @@

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %>
    <%= item.description_html %>
    + <% when SearchHashtag %> + <% if !thin_mode %> + +
    +
    + <%- else -%> +
    + <% end %> + + + +
    + <%- if item.video_count != 0 -%> +

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    + <%- end -%> +
    + +
    + <%- if item.channel_count != 0 -%> +

    <%= translate_count(locale, "generic_channels_count", item.channel_count, NumberFormatting::Separator) %>

    + <%- end -%> +
    <% when SearchPlaylist, InvidiousPlaylist %> <%- if item.id.starts_with? "RD" -- cgit v1.2.3 From c1a69e4a4a8b581ec743b7b3f741097d6596cb3b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 16 Jul 2023 17:23:23 +0200 Subject: Channels: Use innertube to fetch the community tab --- src/invidious/channels/community.cr | 56 ++++++++++++---------------------- src/invidious/yt_backend/extractors.cr | 26 ++++++++++------ 2 files changed, 35 insertions(+), 47 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index aac4bc8a..1a54a946 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -1,49 +1,31 @@ private IMAGE_QUALITIES = {320, 560, 640, 1280, 2000} # TODO: Add "sort_by" -def fetch_channel_community(ucid, continuation, locale, format, thin_mode) - response = YT_POOL.client &.get("/channel/#{ucid}/community?gl=US&hl=en") - if response.status_code != 200 - response = YT_POOL.client &.get("/user/#{ucid}/community?gl=US&hl=en") - end - - if response.status_code != 200 - raise NotFoundException.new("This channel does not exist.") - end - - ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"] - - if !continuation || continuation.empty? - initial_data = extract_initial_data(response.body) - body = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"] - - if !body - raise InfoException.new("Could not extract community tab.") +def fetch_channel_community(ucid, cursor, locale, format, thin_mode) + if cursor.nil? + # Egljb21tdW5pdHk%3D is the protobuf object to load "community" + initial_data = YoutubeAPI.browse(ucid, params: "Egljb21tdW5pdHk%3D") + + items = [] of JSON::Any + extract_items(initial_data) do |item| + items << item end else - continuation = produce_channel_community_continuation(ucid, continuation) - - headers = HTTP::Headers.new - headers["cookie"] = response.cookies.add_request_headers(headers)["cookie"] - - session_token = response.body.match(/"XSRF_TOKEN":"(?[^"]+)"/).try &.["session_token"]? || "" - post_req = { - session_token: session_token, - } + continuation = produce_channel_community_continuation(ucid, cursor) + initial_data = YoutubeAPI.browse(continuation: continuation) - body = YoutubeAPI.browse(continuation) + container = initial_data.dig?("continuationContents", "itemSectionContinuation", "contents") - body = body.dig?("continuationContents", "itemSectionContinuation") || - body.dig?("continuationContents", "backstageCommentsContinuation") + raise InfoException.new("Can't extract community data") if container.nil? - if !body - raise InfoException.new("Could not extract continuation.") - end + items = container.as_a end - posts = body["contents"].as_a + return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode) +end - if message = posts[0]["messageRenderer"]? +def extract_channel_community(items, *, ucid, locale, format, thin_mode) + if message = items[0]["messageRenderer"]? error_message = (message["text"]["simpleText"]? || message["text"]["runs"]?.try &.[0]?.try &.["text"]?) .try &.as_s || "" @@ -59,7 +41,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) json.field "authorId", ucid json.field "comments" do json.array do - posts.each do |post| + items.each do |post| comments = post["backstagePostThreadRenderer"]?.try &.["comments"]? || post["backstageCommentsContinuation"]? @@ -242,7 +224,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end end end - if cont = posts.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token") + if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token") json.field "continuation", extract_channel_community_cursor(cont.as_s) end end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index e5029dc5..8cf59d50 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -608,19 +608,25 @@ private module Extractors private def self.unpack_section_list(contents) raw_items = [] of JSON::Any - contents.as_a.each do |renderer_container| - renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] - - # Category extraction - if items_container = renderer_container_contents["shelfRenderer"]? - raw_items << renderer_container_contents - next - elsif items_container = renderer_container_contents["gridRenderer"]? + contents.as_a.each do |item| + if item_section_content = item.dig?("itemSectionRenderer", "contents") + raw_items += self.unpack_item_section(item_section_content) else - items_container = renderer_container_contents + raw_items << item end + end - items_container["items"]?.try &.as_a.each do |item| + return raw_items + end + + private def self.unpack_item_section(contents) + raw_items = [] of JSON::Any + + contents.as_a.each do |item| + # Category extraction + if container = item.dig?("gridRenderer", "items") || item.dig?("items") + raw_items += container.as_a + else raw_items << item end end -- cgit v1.2.3 From 2e67b90540d35ede212866e1fb597fd57ced35d5 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 22 Jul 2023 23:55:05 -0700 Subject: Add method to query /youtubei/v1/get_transcript --- src/invidious/yt_backend/youtube_api.cr | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 3dd9e9d8..f8aca04d 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -557,6 +557,30 @@ module YoutubeAPI return self._post_json("/youtubei/v1/search", data, client_config) end + #################################################################### + # transcript(params) + # + # Requests the youtubei/v1/get_transcript endpoint with the required headers + # and POST data in order to get a JSON reply. + # + # The requested data is a specially encoded protobuf string that denotes the specific language requested. + # + # An optional ClientConfig parameter can be passed, too (see + # `struct ClientConfig` above for more details). + # + + def transcript( + params : String, + client_config : ClientConfig | Nil = nil + ) : Hash(String, JSON::Any) + data = { + "context" => self.make_context(client_config), + "params" => params, + } + + return self._post_json("/youtubei/v1/get_transcript", data, client_config) + end + #################################################################### # _post_json(endpoint, data, client_config?) # -- cgit v1.2.3 From 7e5935a9da5355bbdd4c047edf692b0ce57722c7 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 23 Jul 2023 00:54:43 -0700 Subject: Rename Caption struct to CaptionMetadata The Caption object does not actually store any text lines for the subtitles. Instead it stores the metadata needed to display and fetch the actual captions from the YT timedtext API. Therefore it may be wiser to rename the struct to be more reflective of its current usage as well as the future usage once the current caption retrival system is replaced via InnerTube's transcript API --- src/invidious/frontend/watch_page.cr | 2 +- src/invidious/videos.cr | 6 +++--- src/invidious/videos/caption.cr | 8 ++++---- src/invidious/views/user/preferences.ecr | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index e3214469..b860dba7 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -7,7 +7,7 @@ module Invidious::Frontend::WatchPage getter full_videos : Array(Hash(String, JSON::Any)) getter video_streams : Array(Hash(String, JSON::Any)) getter audio_streams : Array(Hash(String, JSON::Any)) - getter captions : Array(Invidious::Videos::Caption) + getter captions : Array(Invidious::Videos::CaptionMetadata) def initialize( @full_videos, diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index f38b33e5..2b1d2603 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -24,7 +24,7 @@ struct Video property updated : Time @[DB::Field(ignore: true)] - @captions = [] of Invidious::Videos::Caption + @captions = [] of Invidious::Videos::CaptionMetadata @[DB::Field(ignore: true)] property adaptive_fmts : Array(Hash(String, JSON::Any))? @@ -215,9 +215,9 @@ struct Video keywords.includes? "YouTube Red" end - def captions : Array(Invidious::Videos::Caption) + def captions : Array(Invidious::Videos::CaptionMetadata) if @captions.empty? && @info.has_key?("captions") - @captions = Invidious::Videos::Caption.from_yt_json(info["captions"]) + @captions = Invidious::Videos::CaptionMetadata.from_yt_json(info["captions"]) end return @captions diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 13f81a31..c85b46c3 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -1,7 +1,7 @@ require "json" module Invidious::Videos - struct Caption + struct CaptionMetadata property name : String property language_code : String property base_url : String @@ -10,12 +10,12 @@ module Invidious::Videos end # Parse the JSON structure from Youtube - def self.from_yt_json(container : JSON::Any) : Array(Caption) + def self.from_yt_json(container : JSON::Any) : Array(CaptionMetadata) caption_tracks = container .dig?("playerCaptionsTracklistRenderer", "captionTracks") .try &.as_a - captions_list = [] of Caption + captions_list = [] of CaptionMetadata return captions_list if caption_tracks.nil? caption_tracks.each do |caption| @@ -25,7 +25,7 @@ module Invidious::Videos language_code = caption["languageCode"].to_s base_url = caption["baseUrl"].to_s - captions_list << Caption.new(name, language_code, base_url) + captions_list << CaptionMetadata.new(name, language_code, base_url) end return captions_list diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index dfda1434..b1061ee8 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -89,7 +89,7 @@ <% preferences.captions.each_with_index do |caption, index| %> -- cgit v1.2.3 From 8e18d445a7adf9a0c0887249003a7b84f0fb95af Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 23 Jul 2023 01:52:53 -0700 Subject: Add method to generate params for transcripts api --- src/invidious/videos/transcript.cr | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/invidious/videos/transcript.cr (limited to 'src') diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr new file mode 100644 index 00000000..c50f7569 --- /dev/null +++ b/src/invidious/videos/transcript.cr @@ -0,0 +1,34 @@ +module Invidious::Videos + # Namespace for methods primarily relating to Transcripts + module Transcript + def self.generate_param(video_id : String, language_code : String, auto_generated : Bool) : String + if !auto_generated + is_auto_generated = "" + elsif is_auto_generated = "asr" + end + + object = { + "1:0:string" => video_id, + + "2:base64" => { + "1:string" => is_auto_generated, + "2:string" => language_code, + "3:string" => "", + }, + + "3:varint" => 1_i64, + "5:string" => "engagement-panel-searchable-transcript-search-panel", + "6:varint" => 1_i64, + "7:varint" => 1_i64, + "8:varint" => 1_i64, + } + + params = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return params + end + end +end -- cgit v1.2.3 From 4b3ac1a757a5ee14919e83a84de31a3d0bd14a4c Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 23 Jul 2023 03:22:19 -0700 Subject: Add method to parse transcript JSON into structs --- src/invidious/videos/transcript.cr | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) (limited to 'src') diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index c50f7569..0d8b0b25 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -1,6 +1,8 @@ module Invidious::Videos # Namespace for methods primarily relating to Transcripts module Transcript + record TranscriptLine, start_ms : Time::Span, end_ms : Time::Span, line : String + def self.generate_param(video_id : String, language_code : String, auto_generated : Bool) : String if !auto_generated is_auto_generated = "" @@ -30,5 +32,40 @@ module Invidious::Videos return params end + + def self.convert_transcripts_to_vtt(initial_data : JSON::Any, target_language : String) : String + # Convert into TranscriptLine + + vtt = String.build do |vtt| + result << <<-END_VTT + WEBVTT + Kind: captions + Language: #{tlang} + + + END_VTT + + vtt << "\n\n" + end + end + + def self.parse(initial_data : Hash(String, JSON::Any)) + body = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", + "content", "transcriptSearchPanelRenderer", "body", "transcriptSegmentListRenderer", + "initialSegments").as_a + + lines = [] of TranscriptLine + body.each do |line| + line = line["transcriptSegmentRenderer"] + start_ms = line["startMs"].as_s.to_i.millisecond + end_ms = line["endMs"].as_s.to_i.millisecond + + text = extract_text(line["snippet"]) || "" + + lines << TranscriptLine.new(start_ms, end_ms, text) + end + + return lines + end end end -- cgit v1.2.3 From caac7e21668dd88eaf3d57ddc300427885af0a23 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 23 Jul 2023 03:52:26 -0700 Subject: Add method to convert transcripts response to vtt --- src/invidious/videos/transcript.cr | 39 +++++++++++++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index 0d8b0b25..ec990883 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -33,23 +33,52 @@ module Invidious::Videos return params end - def self.convert_transcripts_to_vtt(initial_data : JSON::Any, target_language : String) : String - # Convert into TranscriptLine + def self.convert_transcripts_to_vtt(initial_data : Hash(String, JSON::Any), target_language : String) : String + # Convert into array of TranscriptLine + lines = self.parse(initial_data) + # Taken from Invidious::Videos::CaptionMetadata.timedtext_to_vtt() vtt = String.build do |vtt| - result << <<-END_VTT + vtt << <<-END_VTT WEBVTT Kind: captions - Language: #{tlang} + Language: #{target_language} END_VTT vtt << "\n\n" + + lines.each do |line| + start_time = line.start_ms + end_time = line.end_ms + + # start_time + vtt << start_time.hours.to_s.rjust(2, '0') + vtt << ':' << start_time.minutes.to_s.rjust(2, '0') + vtt << ':' << start_time.seconds.to_s.rjust(2, '0') + vtt << '.' << start_time.milliseconds.to_s.rjust(3, '0') + + vtt << " --> " + + # end_time + vtt << end_time.hours.to_s.rjust(2, '0') + vtt << ':' << end_time.minutes.to_s.rjust(2, '0') + vtt << ':' << end_time.seconds.to_s.rjust(2, '0') + vtt << '.' << end_time.milliseconds.to_s.rjust(3, '0') + + vtt << "\n" + vtt << line.line + + vtt << "\n" + vtt << "\n" + end end + + return vtt end - def self.parse(initial_data : Hash(String, JSON::Any)) + private def self.parse(initial_data : Hash(String, JSON::Any)) body = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", "content", "transcriptSearchPanelRenderer", "body", "transcriptSegmentListRenderer", "initialSegments").as_a -- cgit v1.2.3 From e4942b188f5c192d5693687698db9b106571332c Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 23 Jul 2023 05:02:02 -0700 Subject: Integrate transcript captions into captions API --- config/config.example.yml | 13 ++++ src/invidious/config.cr | 3 + src/invidious/routes/api/v1/videos.cr | 114 ++++++++++++++++++---------------- src/invidious/videos/caption.cr | 11 +++- src/invidious/videos/transcript.cr | 6 ++ 5 files changed, 92 insertions(+), 55 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 34070fe5..51beab89 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -182,6 +182,19 @@ https_only: false #force_resolve: +## +## Use Innertube's transcripts API instead of timedtext for closed captions +## +## Useful for larger instances as InnerTube is **not ratelimited**. See https://github.com/iv-org/invidious/issues/2567 +## +## Subtitle experience may differ slightly on Invidious. +## +## Accepted values: true, false +## Default: false +## +# use_innertube_for_captions: false + + # ----------------------------- # Logging # ----------------------------- diff --git a/src/invidious/config.cr b/src/invidious/config.cr index e5f1e822..c88a4837 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -129,6 +129,9 @@ class Config # Use quic transport for youtube api property use_quic : Bool = false + # Use Innertube's transcripts API instead of timedtext for closed captions + property use_innertube_for_captions : Bool = false + # Saved cookies in "name1=value1; name2=value2..." format @[YAML::Field(converter: Preferences::StringToCookies)] property cookies : HTTP::Cookies = HTTP::Cookies.new diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index af4fc806..000e64b9 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -87,70 +87,78 @@ module Invidious::Routes::API::V1::Videos caption = caption[0] end - url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target - - # Auto-generated captions often have cues that aren't aligned properly with the video, - # as well as some other markup that makes it cumbersome, so we try to fix that here - if caption.name.includes? "auto-generated" - caption_xml = YT_POOL.client &.get(url).body - - if caption_xml.starts_with?(" i + 1 + end_time = caption_nodes[i + 1]["start"].to_f.seconds + else + end_time = start_time + duration + end - END_VTT + start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" + end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - caption_nodes = caption_xml.xpath_nodes("//transcript/text") - caption_nodes.each_with_index do |node, i| - start_time = node["start"].to_f.seconds - duration = node["dur"]?.try &.to_f.seconds - duration ||= start_time + text = HTML.unescape(node.content) + text = text.gsub(//, "") + text = text.gsub(/<\/font>/, "") + if md = text.match(/(?.*) : (?.*)/) + text = "#{md["text"]}" + end - if caption_nodes.size > i + 1 - end_time = caption_nodes[i + 1]["start"].to_f.seconds - else - end_time = start_time + duration - end + str << <<-END_CUE + #{start_time} --> #{end_time} + #{text} - start_time = "#{start_time.hours.to_s.rjust(2, '0')}:#{start_time.minutes.to_s.rjust(2, '0')}:#{start_time.seconds.to_s.rjust(2, '0')}.#{start_time.milliseconds.to_s.rjust(3, '0')}" - end_time = "#{end_time.hours.to_s.rjust(2, '0')}:#{end_time.minutes.to_s.rjust(2, '0')}:#{end_time.seconds.to_s.rjust(2, '0')}.#{end_time.milliseconds.to_s.rjust(3, '0')}" - text = HTML.unescape(node.content) - text = text.gsub(//, "") - text = text.gsub(/<\/font>/, "") - if md = text.match(/(?.*) : (?.*)/) - text = "#{md["text"]}" + END_CUE end - - str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE end end - end - else - # Some captions have "align:[start/end]" and "position:[num]%" - # attributes. Those are causing issues with VideoJS, which is unable - # to properly align the captions on the video, so we remove them. - # - # See: https://github.com/iv-org/invidious/issues/2391 - webvtt = YT_POOL.client &.get("#{url}&format=vtt").body - if webvtt.starts_with?(" [0-9:.]{12}).+/, "\\1") + if webvtt.starts_with?(" [0-9:.]{12}).+/, "\\1") + end end end diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index c85b46c3..1e2abde9 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -6,7 +6,9 @@ module Invidious::Videos property language_code : String property base_url : String - def initialize(@name, @language_code, @base_url) + property auto_generated : Bool + + def initialize(@name, @language_code, @base_url, @auto_generated) end # Parse the JSON structure from Youtube @@ -25,7 +27,12 @@ module Invidious::Videos language_code = caption["languageCode"].to_s base_url = caption["baseUrl"].to_s - captions_list << CaptionMetadata.new(name, language_code, base_url) + auto_generated = false + if caption["kind"]? && caption["kind"] == "asr" + auto_generated = true + end + + captions_list << CaptionMetadata.new(name, language_code, base_url, auto_generated) end return captions_list diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index ec990883..ba2728cd 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -85,7 +85,13 @@ module Invidious::Videos lines = [] of TranscriptLine body.each do |line| + # Transcript section headers. They are not apart of the captions and as such we can safely skip them. + if line.as_h.has_key?("transcriptSectionHeaderRenderer") + next + end + line = line["transcriptSegmentRenderer"] + start_ms = line["startMs"].as_s.to_i.millisecond end_ms = line["endMs"].as_s.to_i.millisecond -- cgit v1.2.3 From 3509752b791b12bcf20e12656e3b871e5034b1a7 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 23 Jul 2023 16:50:40 -0700 Subject: Rename transcript() to get_transcript() in YT API --- src/invidious/routes/api/v1/videos.cr | 2 +- src/invidious/yt_backend/youtube_api.cr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 000e64b9..25e766d2 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -89,7 +89,7 @@ module Invidious::Routes::API::V1::Videos if CONFIG.use_innertube_for_captions params = Invidious::Videos::Transcript.generate_param(id, caption.language_code, caption.auto_generated) - initial_data = YoutubeAPI.transcript(params.to_s) + initial_data = YoutubeAPI.get_transcript(params) webvtt = Invidious::Videos::Transcript.convert_transcripts_to_vtt(initial_data, caption.language_code) else diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index f8aca04d..a3335bbf 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -558,7 +558,7 @@ module YoutubeAPI end #################################################################### - # transcript(params) + # get_transcript(params, client_config?) # # Requests the youtubei/v1/get_transcript endpoint with the required headers # and POST data in order to get a JSON reply. @@ -569,7 +569,7 @@ module YoutubeAPI # `struct ClientConfig` above for more details). # - def transcript( + def get_transcript( params : String, client_config : ClientConfig | Nil = nil ) : Hash(String, JSON::Any) -- cgit v1.2.3 From c5fe96e93603db58d6767928eedc658e8b58e59f Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 26 Jul 2023 07:19:12 -0700 Subject: Remove lsquic from codebase --- config/config.example.yml | 21 ---- shard.lock | 4 - shard.yml | 3 - src/invidious.cr | 2 +- src/invidious/config.cr | 2 - src/invidious/routes/images.cr | 142 ++++------------------------ src/invidious/yt_backend/connection_pool.cr | 37 ++------ src/invidious/yt_backend/youtube_api.cr | 14 +-- 8 files changed, 32 insertions(+), 193 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 34070fe5..e925a5e3 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -140,27 +140,6 @@ https_only: false ## #pool_size: 100 -## -## Enable/Disable the use of QUIC (HTTP/3) when connecting -## to the youtube API and websites ('youtube.com', 'ytimg.com'). -## QUIC's main advantages are its lower latency and lower bandwidth -## use, compared to its predecessors. However, the current version -## of QUIC used in invidious is still based on the IETF draft 31, -## meaning that the underlying library may still not be fully -## optimized. You can read more about QUIC at the link below: -## https://datatracker.ietf.org/doc/html/draft-ietf-quic-transport-31 -## -## Note: you should try both options and see what is the best for your -## instance. In general QUIC is recommended for public instances. Your -## mileage may vary. -## -## Note 2: Using QUIC prevents some captcha challenges from appearing. -## See: https://github.com/iv-org/invidious/issues/957#issuecomment-576424042 -## -## Accepted values: true, false -## Default: false -## -#use_quic: false ## ## Additional cookies to be sent when requesting the youtube API. diff --git a/shard.lock b/shard.lock index 235e4c25..55fcfe46 100644 --- a/shard.lock +++ b/shard.lock @@ -24,10 +24,6 @@ shards: git: https://github.com/jeromegn/kilt.git version: 0.6.1 - lsquic: - git: https://github.com/iv-org/lsquic.cr.git - version: 2.18.1-2 - pg: git: https://github.com/will/crystal-pg.git version: 0.24.0 diff --git a/shard.yml b/shard.yml index 7ee0bb2a..e929160d 100644 --- a/shard.yml +++ b/shard.yml @@ -25,9 +25,6 @@ dependencies: protodec: github: iv-org/protodec version: ~> 0.1.5 - lsquic: - github: iv-org/lsquic.cr - version: ~> 2.18.1-2 athena-negotiation: github: athena-framework/negotiation version: ~> 0.1.1 diff --git a/src/invidious.cr b/src/invidious.cr index 84e1895d..e0bd0101 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -90,7 +90,7 @@ SOFTWARE = { "branch" => "#{CURRENT_BRANCH}", } -YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size, use_quic: CONFIG.use_quic) +YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size) # CLI Kemal.config.extra_options do |parser| diff --git a/src/invidious/config.cr b/src/invidious/config.cr index e5f1e822..cee33ce1 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -126,8 +126,6 @@ class Config property host_binding : String = "0.0.0.0" # Pool size for HTTP requests to youtube.com and ytimg.com (each domain has a separate pool of `pool_size`) property pool_size : Int32 = 100 - # Use quic transport for youtube api - property use_quic : Bool = false # Saved cookies in "name1=value1; name2=value2..." format @[YAML::Field(converter: Preferences::StringToCookies)] diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index 594a7869..b6a2e110 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -3,17 +3,7 @@ module Invidious::Routes::Images def self.ggpht(env) url = env.request.path.lchop("/ggpht") - headers = ( - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - HTTP::Headers{":authority" => "yt3.ggpht.com"} - else - HTTP::Headers.new - end - {% else %} - HTTP::Headers.new - {% end %} - ) + headers = HTTP::Headers.new REQUEST_HEADERS_WHITELIST.each do |header| if env.request.headers[header]? @@ -42,22 +32,9 @@ module Invidious::Routes::Images } begin - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - YT_POOL.client &.get(url, headers) do |resp| - return request_proc.call(resp) - end - else - HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp| - return request_proc.call(resp) - end - end - {% else %} - # This can likely be optimized into a (small) pool sometime in the future. - HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp| - return request_proc.call(resp) - end - {% end %} + HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp| + return request_proc.call(resp) + end rescue ex end end @@ -78,10 +55,6 @@ module Invidious::Routes::Images headers = HTTP::Headers.new - {% unless flag?(:disable_quic) %} - headers[":authority"] = "#{authority}.ytimg.com" - {% end %} - REQUEST_HEADERS_WHITELIST.each do |header| if env.request.headers[header]? headers[header] = env.request.headers[header] @@ -107,22 +80,9 @@ module Invidious::Routes::Images } begin - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - YT_POOL.client &.get(url, headers) do |resp| - return request_proc.call(resp) - end - else - HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - end - {% else %} - # This can likely be optimized into a (small) pool sometime in the future. - HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - {% end %} + HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end rescue ex end end @@ -133,17 +93,7 @@ module Invidious::Routes::Images name = env.params.url["name"] url = env.request.resource - headers = ( - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - HTTP::Headers{":authority" => "i9.ytimg.com"} - else - HTTP::Headers.new - end - {% else %} - HTTP::Headers.new - {% end %} - ) + headers = HTTP::Headers.new REQUEST_HEADERS_WHITELIST.each do |header| if env.request.headers[header]? @@ -169,22 +119,9 @@ module Invidious::Routes::Images } begin - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - YT_POOL.client &.get(url, headers) do |resp| - return request_proc.call(resp) - end - else - HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - end - {% else %} - # This can likely be optimized into a (small) pool sometime in the future. - HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - {% end %} + HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end rescue ex end end @@ -223,41 +160,16 @@ module Invidious::Routes::Images id = env.params.url["id"] name = env.params.url["name"] - headers = ( - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - HTTP::Headers{":authority" => "i.ytimg.com"} - else - HTTP::Headers.new - end - {% else %} - HTTP::Headers.new - {% end %} - ) + headers = HTTP::Headers.new if name == "maxres.jpg" build_thumbnails(id).each do |thumb| thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg" - # Logic here is short enough that manually typing them out should be fine. - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - if YT_POOL.client &.head(thumbnail_resource_path, headers).status_code == 200 - name = thumb[:url] + ".jpg" - break - end - else - if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200 - name = thumb[:url] + ".jpg" - break - end - end - {% else %} - # This can likely be optimized into a (small) pool sometime in the future. - if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200 - name = thumb[:url] + ".jpg" - break - end - {% end %} + # This can likely be optimized into a (small) pool sometime in the future. + if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200 + name = thumb[:url] + ".jpg" + break + end end end @@ -287,22 +199,10 @@ module Invidious::Routes::Images } begin - {% unless flag?(:disable_quic) %} - if CONFIG.use_quic - YT_POOL.client &.get(url, headers) do |resp| - return request_proc.call(resp) - end - else - HTTP::Client.get("https://i.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - end - {% else %} - # This can likely be optimized into a (small) pool sometime in the future. - HTTP::Client.get("https://i.ytimg.com#{url}") do |resp| - return request_proc.call(resp) - end - {% end %} + # This can likely be optimized into a (small) pool sometime in the future. + HTTP::Client.get("https://i.ytimg.com#{url}") do |resp| + return request_proc.call(resp) + end rescue ex end end diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 658731cf..e9eb726c 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -1,11 +1,3 @@ -{% unless flag?(:disable_quic) %} - require "lsquic" - - alias HTTPClientType = QUIC::Client | HTTP::Client -{% else %} - alias HTTPClientType = HTTP::Client -{% end %} - def add_yt_headers(request) if request.headers["User-Agent"] == "Crystal" request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36" @@ -26,11 +18,11 @@ struct YoutubeConnectionPool property! url : URI property! capacity : Int32 property! timeout : Float64 - property pool : DB::Pool(HTTPClientType) + property pool : DB::Pool(HTTP::Client) - def initialize(url : URI, @capacity = 5, @timeout = 5.0, use_quic = true) + def initialize(url : URI, @capacity = 5, @timeout = 5.0) @url = url - @pool = build_pool(use_quic) + @pool = build_pool() end def client(region = nil, &block) @@ -43,11 +35,7 @@ struct YoutubeConnectionPool response = yield conn rescue ex conn.close - {% unless flag?(:disable_quic) %} - conn = CONFIG.use_quic ? QUIC::Client.new(url) : HTTP::Client.new(url) - {% else %} - conn = HTTP::Client.new(url) - {% end %} + conn = HTTP::Client.new(url) conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC @@ -61,19 +49,9 @@ struct YoutubeConnectionPool response end - private def build_pool(use_quic) - DB::Pool(HTTPClientType).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do - conn = nil # Declare - {% unless flag?(:disable_quic) %} - if use_quic - conn = QUIC::Client.new(url) - else - conn = HTTP::Client.new(url) - end - {% else %} - conn = HTTP::Client.new(url) - {% end %} - + private def build_pool + DB::Pool(HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do + conn = HTTP::Client.new(url) conn.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::INET conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" @@ -83,7 +61,6 @@ struct YoutubeConnectionPool end def make_client(url : URI, region = nil) - # TODO: Migrate any applicable endpoints to QUIC client = HTTPClient.new(url, OpenSSL::SSL::Context::Client.insecure) client.family = (url.host == "www.youtube.com") ? CONFIG.force_resolve : Socket::Family::UNSPEC client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 3dd9e9d8..aef9ddd9 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -595,17 +595,9 @@ module YoutubeAPI LOGGER.trace("YoutubeAPI: POST data: #{data}") # Send the POST request - if {{ !flag?(:disable_quic) }} && CONFIG.use_quic - # Using QUIC client - body = YT_POOL.client(client_config.proxy_region, - &.post(url, headers: headers, body: data.to_json) - ).body - else - # Using HTTP client - body = YT_POOL.client(client_config.proxy_region) do |client| - client.post(url, headers: headers, body: data.to_json) do |response| - self._decompress(response.body_io, response.headers["Content-Encoding"]?) - end + body = YT_POOL.client(client_config.proxy_region) do |client| + client.post(url, headers: headers, body: data.to_json) do |response| + self._decompress(response.body_io, response.headers["Content-Encoding"]?) end end -- cgit v1.2.3 From 2f6b2688bb8042c29942e46767dc78836f21fb57 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 6 Aug 2023 12:20:05 -0700 Subject: Use workaround for fetching streaming URLs YouTube appears to be A/B testing some new integrity checks. Adding the parameter "CgIQBg" to InnerTube player requests appears to workaround the problem See https://github.com/TeamNewPipe/NewPipeExtractor/pull/1084 --- src/invidious/videos/parser.cr | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 9cc0ffdc..2a09d187 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -55,8 +55,9 @@ def extract_video_info(video_id : String, proxy_region : String? = nil) client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) # Fetch data from the player endpoint - # 8AEB param is used to fetch YouTube stories - player_response = YoutubeAPI.player(video_id: video_id, params: "8AEB", client_config: client_config) + # CgIQBg is a workaround for streaming URLs that returns a 403. + # See https://github.com/iv-org/invidious/issues/4027#issuecomment-1666944520 + player_response = YoutubeAPI.player(video_id: video_id, params: "CgIQBg", client_config: client_config) playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s @@ -135,8 +136,9 @@ end def try_fetch_streaming_data(id : String, client_config : YoutubeAPI::ClientConfig) : Hash(String, JSON::Any)? LOGGER.debug("try_fetch_streaming_data: [#{id}] Using #{client_config.client_type} client.") - # 8AEB param is used to fetch YouTube stories - response = YoutubeAPI.player(video_id: id, params: "8AEB", client_config: client_config) + # CgIQBg is a workaround for streaming URLs that returns a 403. + # See https://github.com/iv-org/invidious/issues/4027#issuecomment-1666944520 + response = YoutubeAPI.player(video_id: id, params: "CgIQBg", client_config: client_config) playability_status = response["playabilityStatus"]["status"] LOGGER.debug("try_fetch_streaming_data: [#{id}] Got playabilityStatus == #{playability_status}.") -- cgit v1.2.3 From a81c0f329cfe0ef343c31636b74615e91e613f72 Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 8 Aug 2023 15:13:23 -0700 Subject: Add workaround for storyboards on priv. instances An upstream problem with videojs-vtt-thumbnails means that URLs gets joined incorrectly on any instance where `domain`, `external_port` and `https_only` aren't set. This commit adds some logic with the 404 handler to mitigate this problem. This is however only a workaround. See: https://github.com/iv-org/invidious/issues/3117 https://github.com/chrisboustead/videojs-vtt-thumbnails/issues/31 --- src/invidious/routes/errors.cr | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/errors.cr b/src/invidious/routes/errors.cr index b138b562..4d8d9ee8 100644 --- a/src/invidious/routes/errors.cr +++ b/src/invidious/routes/errors.cr @@ -1,5 +1,10 @@ module Invidious::Routes::ErrorRoutes def self.error_404(env) + # Workaround for # 3117 + if HOST_URL.empty? && env.request.path.starts_with?("/v1/storyboards/sb") + return env.redirect "#{env.request.path[15..]}?#{env.params.query}" + end + if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) item = md["id"] -- cgit v1.2.3 From 6b17bb525095a62b163489c565edb0ca29eb1a93 Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 8 Aug 2023 15:20:48 -0700 Subject: Regression from #4037 | Fix storyboards PR #4037 introduced a workaround around YouTube's new integrity checks on streaming URLs. However, the usage of this workaround prevents storyboard data from being returned by InnerTube. This commit fixes that by only using the workaround when calling try_fetch_streaming_data --- src/invidious/videos/parser.cr | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 2a09d187..06ff96b1 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -55,9 +55,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil) client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) # Fetch data from the player endpoint - # CgIQBg is a workaround for streaming URLs that returns a 403. - # See https://github.com/iv-org/invidious/issues/4027#issuecomment-1666944520 - player_response = YoutubeAPI.player(video_id: video_id, params: "CgIQBg", client_config: client_config) + player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s @@ -120,6 +118,9 @@ def extract_video_info(video_id : String, proxy_region : String? = nil) # Replace player response and reset reason if !new_player_response.nil? + # Preserve storyboard data before replacement + new_player_response["storyboards"] = player_response["storyboards"] if player_response["storyboards"]? + player_response = new_player_response params.delete("reason") end -- cgit v1.2.3 From 2b36d3b419d04fd4fc46e97e03a4c3af7285b663 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Thu, 10 Aug 2023 18:45:10 +0000 Subject: Update errors.cr --- src/invidious/routes/errors.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/errors.cr b/src/invidious/routes/errors.cr index 4d8d9ee8..1e9ab44e 100644 --- a/src/invidious/routes/errors.cr +++ b/src/invidious/routes/errors.cr @@ -1,6 +1,6 @@ module Invidious::Routes::ErrorRoutes def self.error_404(env) - # Workaround for # 3117 + # Workaround for #3117 if HOST_URL.empty? && env.request.path.starts_with?("/v1/storyboards/sb") return env.redirect "#{env.request.path[15..]}?#{env.params.query}" end -- cgit v1.2.3 From 1f7592e599054131c689246b0dd6aad45f2d8e7a Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 24 Aug 2023 16:00:02 -0700 Subject: Refactor structure of caption.cr Rename CaptionsMetadata to Metadata Nest Metadata under Captions Unnest LANGUAGES constant from Metadata to main Captions module --- src/invidious/frontend/watch_page.cr | 2 +- src/invidious/videos.cr | 6 +- src/invidious/videos/caption.cr | 146 ++++++++++++++++--------------- src/invidious/videos/transcript.cr | 2 +- src/invidious/views/user/preferences.ecr | 2 +- 5 files changed, 80 insertions(+), 78 deletions(-) (limited to 'src') diff --git a/src/invidious/frontend/watch_page.cr b/src/invidious/frontend/watch_page.cr index b860dba7..5fd81168 100644 --- a/src/invidious/frontend/watch_page.cr +++ b/src/invidious/frontend/watch_page.cr @@ -7,7 +7,7 @@ module Invidious::Frontend::WatchPage getter full_videos : Array(Hash(String, JSON::Any)) getter video_streams : Array(Hash(String, JSON::Any)) getter audio_streams : Array(Hash(String, JSON::Any)) - getter captions : Array(Invidious::Videos::CaptionMetadata) + getter captions : Array(Invidious::Videos::Captions::Metadata) def initialize( @full_videos, diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 2b1d2603..9fbd1374 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -24,7 +24,7 @@ struct Video property updated : Time @[DB::Field(ignore: true)] - @captions = [] of Invidious::Videos::CaptionMetadata + @captions = [] of Invidious::Videos::Captions::Metadata @[DB::Field(ignore: true)] property adaptive_fmts : Array(Hash(String, JSON::Any))? @@ -215,9 +215,9 @@ struct Video keywords.includes? "YouTube Red" end - def captions : Array(Invidious::Videos::CaptionMetadata) + def captions : Array(Invidious::Videos::Captions::Metadata) if @captions.empty? && @info.has_key?("captions") - @captions = Invidious::Videos::CaptionMetadata.from_yt_json(info["captions"]) + @captions = Invidious::Videos::Captions::Metadata.from_yt_json(info["captions"]) end return @captions diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 1e2abde9..82b68dcd 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -1,107 +1,109 @@ require "json" module Invidious::Videos - struct CaptionMetadata - property name : String - property language_code : String - property base_url : String + module Captions + struct Metadata + property name : String + property language_code : String + property base_url : String - property auto_generated : Bool + property auto_generated : Bool - def initialize(@name, @language_code, @base_url, @auto_generated) - end + def initialize(@name, @language_code, @base_url, @auto_generated) + end - # Parse the JSON structure from Youtube - def self.from_yt_json(container : JSON::Any) : Array(CaptionMetadata) - caption_tracks = container - .dig?("playerCaptionsTracklistRenderer", "captionTracks") - .try &.as_a + # Parse the JSON structure from Youtube + def self.from_yt_json(container : JSON::Any) : Array(Captions::Metadata) + caption_tracks = container + .dig?("playerCaptionsTracklistRenderer", "captionTracks") + .try &.as_a - captions_list = [] of CaptionMetadata - return captions_list if caption_tracks.nil? + captions_list = [] of Captions::Metadata + return captions_list if caption_tracks.nil? - caption_tracks.each do |caption| - name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] - name = name.to_s.split(" - ")[0] + caption_tracks.each do |caption| + name = caption["name"]["simpleText"]? || caption["name"]["runs"][0]["text"] + name = name.to_s.split(" - ")[0] - language_code = caption["languageCode"].to_s - base_url = caption["baseUrl"].to_s + language_code = caption["languageCode"].to_s + base_url = caption["baseUrl"].to_s - auto_generated = false - if caption["kind"]? && caption["kind"] == "asr" - auto_generated = true + auto_generated = false + if caption["kind"]? && caption["kind"] == "asr" + auto_generated = true + end + + captions_list << Captions::Metadata.new(name, language_code, base_url, auto_generated) end - captions_list << CaptionMetadata.new(name, language_code, base_url, auto_generated) + return captions_list end - return captions_list - end - - def timedtext_to_vtt(timedtext : String, tlang = nil) : String - # In the future, we could just directly work with the url. This is more of a POC - cues = [] of XML::Node - tree = XML.parse(timedtext) - tree = tree.children.first - - tree.children.each do |item| - if item.name == "body" - item.children.each do |cue| - if cue.name == "p" && !(cue.children.size == 1 && cue.children[0].content == "\n") - cues << cue + def timedtext_to_vtt(timedtext : String, tlang = nil) : String + # In the future, we could just directly work with the url. This is more of a POC + cues = [] of XML::Node + tree = XML.parse(timedtext) + tree = tree.children.first + + tree.children.each do |item| + if item.name == "body" + item.children.each do |cue| + if cue.name == "p" && !(cue.children.size == 1 && cue.children[0].content == "\n") + cues << cue + end end + break end - break end - end - result = String.build do |result| - result << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || @language_code} + result = String.build do |result| + result << <<-END_VTT + WEBVTT + Kind: captions + Language: #{tlang || @language_code} - END_VTT + END_VTT - result << "\n\n" + result << "\n\n" - cues.each_with_index do |node, i| - start_time = node["t"].to_f.milliseconds + cues.each_with_index do |node, i| + start_time = node["t"].to_f.milliseconds - duration = node["d"]?.try &.to_f.milliseconds + duration = node["d"]?.try &.to_f.milliseconds - duration ||= start_time + duration ||= start_time - if cues.size > i + 1 - end_time = cues[i + 1]["t"].to_f.milliseconds - else - end_time = start_time + duration - end + if cues.size > i + 1 + end_time = cues[i + 1]["t"].to_f.milliseconds + else + end_time = start_time + duration + end - # start_time - result << start_time.hours.to_s.rjust(2, '0') - result << ':' << start_time.minutes.to_s.rjust(2, '0') - result << ':' << start_time.seconds.to_s.rjust(2, '0') - result << '.' << start_time.milliseconds.to_s.rjust(3, '0') + # start_time + result << start_time.hours.to_s.rjust(2, '0') + result << ':' << start_time.minutes.to_s.rjust(2, '0') + result << ':' << start_time.seconds.to_s.rjust(2, '0') + result << '.' << start_time.milliseconds.to_s.rjust(3, '0') - result << " --> " + result << " --> " - # end_time - result << end_time.hours.to_s.rjust(2, '0') - result << ':' << end_time.minutes.to_s.rjust(2, '0') - result << ':' << end_time.seconds.to_s.rjust(2, '0') - result << '.' << end_time.milliseconds.to_s.rjust(3, '0') + # end_time + result << end_time.hours.to_s.rjust(2, '0') + result << ':' << end_time.minutes.to_s.rjust(2, '0') + result << ':' << end_time.seconds.to_s.rjust(2, '0') + result << '.' << end_time.milliseconds.to_s.rjust(3, '0') - result << "\n" + result << "\n" - node.children.each do |s| - result << s.content + node.children.each do |s| + result << s.content + end + result << "\n" + result << "\n" end - result << "\n" - result << "\n" end + return result end - return result end # List of all caption languages available on Youtube. diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index ba2728cd..c86b3988 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -37,7 +37,7 @@ module Invidious::Videos # Convert into array of TranscriptLine lines = self.parse(initial_data) - # Taken from Invidious::Videos::CaptionMetadata.timedtext_to_vtt() + # Taken from Invidious::Videos::Captions::Metadata.timedtext_to_vtt() vtt = String.build do |vtt| vtt << <<-END_VTT WEBVTT diff --git a/src/invidious/views/user/preferences.ecr b/src/invidious/views/user/preferences.ecr index b1061ee8..55349c5a 100644 --- a/src/invidious/views/user/preferences.ecr +++ b/src/invidious/views/user/preferences.ecr @@ -89,7 +89,7 @@ <% preferences.captions.each_with_index do |caption, index| %> -- cgit v1.2.3 From 7d435f082bf24c1122c95ecc92efee4a39a7b539 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Thu, 24 Aug 2023 23:20:20 +0000 Subject: Update src/invidious/videos/transcript.cr Co-authored-by: Samantaz Fox --- src/invidious/videos/transcript.cr | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index c86b3988..f3360a52 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -4,16 +4,13 @@ module Invidious::Videos record TranscriptLine, start_ms : Time::Span, end_ms : Time::Span, line : String def self.generate_param(video_id : String, language_code : String, auto_generated : Bool) : String - if !auto_generated - is_auto_generated = "" - elsif is_auto_generated = "asr" - end + kind = auto_generated ? "asr" : "" object = { "1:0:string" => video_id, "2:base64" => { - "1:string" => is_auto_generated, + "1:string" => kind, "2:string" => language_code, "3:string" => "", }, -- cgit v1.2.3 From 3615bb0e62209cfad4825e8c40d8e6de69aac687 Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 24 Aug 2023 16:21:05 -0700 Subject: Update src/invidious/videos/caption.cr Co-authored-by: Samantaz Fox --- src/invidious/videos/caption.cr | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 82b68dcd..256dfcc0 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -28,10 +28,7 @@ module Invidious::Videos language_code = caption["languageCode"].to_s base_url = caption["baseUrl"].to_s - auto_generated = false - if caption["kind"]? && caption["kind"] == "asr" - auto_generated = true - end + auto_generated = (caption["kind"]? == "asr") captions_list << Captions::Metadata.new(name, language_code, base_url, auto_generated) end -- cgit v1.2.3 From d7696574f4a281d7450176097c87bca08705734a Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 1 Aug 2023 08:55:23 -0700 Subject: Playlist: Use subtitle when author is missing --- src/invidious/playlists.cr | 5 +++++ src/invidious/views/playlist.ecr | 6 +++++- 2 files changed, 10 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 013be268..955e0855 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -89,6 +89,7 @@ struct Playlist property views : Int64 property updated : Time property thumbnail : String? + property subtitle : String? def to_json(offset, json : JSON::Builder, video_id : String? = nil) json.object do @@ -100,6 +101,7 @@ struct Playlist json.field "author", self.author json.field "authorId", self.ucid json.field "authorUrl", "/channel/#{self.ucid}" + json.field "subtitle", self.subtitle json.field "authorThumbnails" do json.array do @@ -356,6 +358,8 @@ def fetch_playlist(plid : String) updated = Time.utc video_count = 0 + subtitle = extract_text(initial_data.dig?("header", "playlistHeaderRenderer", "subtitle")) + playlist_info["stats"]?.try &.as_a.each do |stat| text = stat["runs"]?.try &.as_a.map(&.["text"].as_s).join("") || stat["simpleText"]?.try &.as_s next if !text @@ -397,6 +401,7 @@ def fetch_playlist(plid : String) views: views, updated: updated, thumbnail: thumbnail, + subtitle: subtitle, }) end diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index ee9ba87b..3bc7596e 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -70,7 +70,11 @@ <% else %> - <%= author %> | + <% if !author.empty? %> + <%= author %> | + <% elsif !playlist.subtitle.nil? %> + <%= playlist.subtitle.try &.split(" • ")[0] %> | + <% end %> <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> | <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> -- cgit v1.2.3 From afb04c3bdaa29f19db44f6560ce7954bc656d791 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Mon, 7 Aug 2023 11:58:20 -0700 Subject: HTMLl.Escape the playlist subtitle --- src/invidious/views/playlist.ecr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 3bc7596e..24ba437d 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -73,7 +73,8 @@ <% if !author.empty? %> <%= author %> | <% elsif !playlist.subtitle.nil? %> - <%= playlist.subtitle.try &.split(" • ")[0] %> | + <% subtitle = playlist.subtitle || "" %> + <%= HTML.escape(subtitle[0..subtitle.rindex(" • ") || subtitle.size]) %> | <% end %> <%= translate_count(locale, "generic_videos_count", playlist.video_count) %> | <%= translate(locale, "Updated `x` ago", recode_date(playlist.updated, locale)) %> -- cgit v1.2.3 From 49b9316b9f2e9ccc6921a2f293abacb37f9805f6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 13 Sep 2023 23:40:20 +0200 Subject: Routing: Handle current and future routes more nicely --- src/invidious/routes/channels.cr | 19 +++++++++++++++---- src/invidious/routing.cr | 36 ++++++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 9892ae2a..5500672f 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -217,6 +217,11 @@ module Invidious::Routes::Channels env.redirect "/channel/#{ucid}" end + private KNOWN_TABS = { + "home", "videos", "shorts", "streams", "podcasts", + "releases", "playlists", "community", "channels", "about", + } + # Redirects brand url channels to a normal /channel/:ucid route def self.brand_redirect(env) locale = env.get("preferences").as(Preferences).locale @@ -227,7 +232,10 @@ module Invidious::Routes::Channels yt_url_params = URI::Params.encode(env.params.query.to_h.select(["a", "u", "user"])) # Retrieves URL params that only Invidious uses - invidious_url_params = URI::Params.encode(env.params.query.to_h.select!(["a", "u", "user"])) + invidious_url_params = env.params.query.dup + invidious_url_params.delete_all("a") + invidious_url_params.delete_all("u") + invidious_url_params.delete_all("user") begin resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}") @@ -236,14 +244,17 @@ module Invidious::Routes::Channels return error_template(404, translate(locale, "This channel does not exist.")) end - selected_tab = env.request.path.split("/")[-1] - if {"home", "videos", "shorts", "streams", "playlists", "community", "channels", "about"}.includes? selected_tab + selected_tab = env.params.url["tab"]? + + if KNOWN_TABS.includes? selected_tab url = "/channel/#{ucid}/#{selected_tab}" else url = "/channel/#{ucid}" end - env.redirect url + url += "?#{invidious_url_params}" if !invidious_url_params.empty? + + return env.redirect url end # Handles redirects for the /profile endpoint diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 9c43171c..5ec7fae3 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -124,22 +124,34 @@ module Invidious::Routing get "/channel/:ucid/community", Routes::Channels, :community get "/channel/:ucid/channels", Routes::Channels, :channels get "/channel/:ucid/about", Routes::Channels, :about + get "/channel/:ucid/live", Routes::Channels, :live get "/user/:user/live", Routes::Channels, :live get "/c/:user/live", Routes::Channels, :live - {"", "/videos", "/shorts", "/streams", "/playlists", "/community", "/about"}.each do |path| - # /c/LinusTechTips - get "/c/:user#{path}", Routes::Channels, :brand_redirect - # /user/linustechtips | Not always the same as /c/ - get "/user/:user#{path}", Routes::Channels, :brand_redirect - # /@LinusTechTips | Handle - get "/@:user#{path}", Routes::Channels, :brand_redirect - # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow - get "/attribution_link#{path}", Routes::Channels, :brand_redirect - # /profile?user=linustechtips - get "/profile/#{path}", Routes::Channels, :profile - end + # Channel catch-all, to redirect future routes to the channel's home + # NOTE: defined last in order to be processed after the other routes + get "/channel/:ucid/*", Routes::Channels, :home + + # /c/LinusTechTips + get "/c/:user", Routes::Channels, :brand_redirect + get "/c/:user/:tab", Routes::Channels, :brand_redirect + + # /user/linustechtips (Not always the same as /c/) + get "/user/:user", Routes::Channels, :brand_redirect + get "/user/:user/:tab", Routes::Channels, :brand_redirect + + # /@LinusTechTips (Handle) + get "/@:user", Routes::Channels, :brand_redirect + get "/@:user/:tab", Routes::Channels, :brand_redirect + + # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow + get "/attribution_link", Routes::Channels, :brand_redirect + get "/attribution_link/:tab", Routes::Channels, :brand_redirect + + # /profile?user=linustechtips + get "/profile", Routes::Channels, :profile + get "/profile/*", Routes::Channels, :profile end def register_watch_routes -- cgit v1.2.3 From 2425c47882feaa56a69f6ba842cf1cb9d5b450e0 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 13 Sep 2023 23:41:31 +0200 Subject: Routing: Add support for the '/live/' route --- src/invidious/routing.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 5ec7fae3..f6b3aaa6 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -158,6 +158,7 @@ module Invidious::Routing get "/watch", Routes::Watch, :handle post "/watch_ajax", Routes::Watch, :mark_watched get "/watch/:id", Routes::Watch, :redirect + get "/live/:id", Routes::Watch, :redirect get "/shorts/:id", Routes::Watch, :redirect get "/clip/:clip", Routes::Watch, :clip get "/w/:id", Routes::Watch, :redirect -- cgit v1.2.3 From 792a999386f9147233d26300856a5802da5fc8c1 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 14 Sep 2023 20:39:46 +0200 Subject: Frontend: Add timestamp on youtube+embed links --- assets/js/player.js | 15 +++++++++++++++ src/invidious/views/watch.ecr | 14 ++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index bb53ac24..cd0e7a72 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -112,6 +112,21 @@ function addCurrentTimeToURL(url) { return urlUsed; } +/** + * Timer that updates the timestamp on "watch on youtube" and "embed" links + */ +player.ready(function () { + let elem_watch = document.getElementById('link-yt-watch'); + let elem_embed = document.getElementById('link-yt-embed'); + + let base_url_watch = elem_watch.getAttribute('data-base-url'); + let base_url_embed = elem_embed.getAttribute('data-base-url'); + + setTimeout(() => { elem_watch.setAttribute('href') = addCurrentTimeToURL(base_url_watch); }, 5000); + setTimeout(() => { elem_embed.setAttribute('href') = addCurrentTimeToURL(base_url_embed); }, 5000); +}); + + var shareOptions = { socials: ['fbFeed', 'tw', 'reddit', 'email'], diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 498d57a1..ac3fee65 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -112,8 +112,18 @@ we're going to need to do it here in order to allow for translations.
    - <%= translate(locale, "videoinfo_watch_on_youTube") %> - (<%= translate(locale, "videoinfo_youTube_embed_link") %>) + <%- + link_yt_watch = URI.new(scheme: "https", host: "www.youtube.com", path: "/watch", query: "v=#{video.id}") + link_yt_embed = URI.new(scheme: "https", host: "www.youtube.com", path: "/embed/#{video.id}") + + if !plid.nil? && !continuation.nil? + link_yt_param = URI::Params{"plid" => [plid], "index" => [continuation.to_s]} + link_yt_watch = IV::HttpServer::Utils.add_params_to_url(link_yt_watch, link_yt_param) + link_yt_embed = IV::HttpServer::Utils.add_params_to_url(link_yt_embed, link_yt_param) + end + -%> + <%= translate(locale, "videoinfo_watch_on_youTube") %> + (<%= translate(locale, "videoinfo_youTube_embed_link") %>)

    <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> -- cgit v1.2.3 From 2456b629365450970363e5cf0e9a65c1a24160ab Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 14 Sep 2023 20:50:17 +0200 Subject: Frontend: Add timestamp on invidious embed links --- assets/js/player.js | 15 +++++++++------ src/invidious/routes/watch.cr | 8 -------- src/invidious/views/watch.ecr | 12 +++++++++++- 3 files changed, 20 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index cd0e7a72..d07d6cf4 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -116,14 +116,17 @@ function addCurrentTimeToURL(url) { * Timer that updates the timestamp on "watch on youtube" and "embed" links */ player.ready(function () { - let elem_watch = document.getElementById('link-yt-watch'); - let elem_embed = document.getElementById('link-yt-embed'); + let elem_yt_watch = document.getElementById('link-yt-watch'); + let elem_yt_embed = document.getElementById('link-yt-embed'); + let elem_iv_embed = document.getElementById('link-iv-embed'); - let base_url_watch = elem_watch.getAttribute('data-base-url'); - let base_url_embed = elem_embed.getAttribute('data-base-url'); + let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url'); + let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url'); + let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url'); - setTimeout(() => { elem_watch.setAttribute('href') = addCurrentTimeToURL(base_url_watch); }, 5000); - setTimeout(() => { elem_embed.setAttribute('href') = addCurrentTimeToURL(base_url_embed); }, 5000); + setTimeout(() => { elem_yt_watch.setAttribute('href') = addCurrentTimeToURL(base_url_yt_watch); }, 5000); + setTimeout(() => { elem_yt_embed.setAttribute('href') = addCurrentTimeToURL(base_url_yt_embed); }, 5000); + setTimeout(() => { elem_iv_embed.setAttribute('href') = addCurrentTimeToURL(base_url_iv_embed); }, 5000); }); diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index e5cf3716..3d935f0a 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -30,14 +30,6 @@ module Invidious::Routes::Watch return env.redirect "/" end - embed_link = "/embed/#{id}" - if env.params.query.size > 1 - embed_params = HTTP::Params.parse(env.params.query.to_s) - embed_params.delete_all("v") - embed_link += "?" - embed_link += embed_params.to_s - end - plid = env.params.query["list"]?.try &.gsub(/[^a-zA-Z0-9_-]/, "") continuation = process_continuation(env.params.query, plid, id) diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index ac3fee65..a768328a 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -125,6 +125,7 @@ we're going to need to do it here in order to allow for translations. <%= translate(locale, "videoinfo_watch_on_youTube") %> (<%= translate(locale, "videoinfo_youTube_embed_link") %>) +

    <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> "><%= translate(locale, "Switch Invidious Instance") %> @@ -132,9 +133,18 @@ we're going to need to do it here in order to allow for translations. <%= translate(locale, "Switch Invidious Instance") %> <% end %>

    + +

    <% if params.annotations %> -- cgit v1.2.3 From 58f4a012b7fde782a83d6745f18c5d080f7ade6a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 14 Sep 2023 22:10:02 +0200 Subject: Frontend: Add timestamp on switch invidious instance links --- assets/js/player.js | 24 +++++++++++++++++++----- src/invidious/views/watch.ecr | 7 ++----- 2 files changed, 21 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index d07d6cf4..bffc7ad3 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -113,20 +113,34 @@ function addCurrentTimeToURL(url) { } /** - * Timer that updates the timestamp on "watch on youtube" and "embed" links + * Timer that updates the timestamp on all external links */ player.ready(function () { + // YouTube links + let elem_yt_watch = document.getElementById('link-yt-watch'); let elem_yt_embed = document.getElementById('link-yt-embed'); - let elem_iv_embed = document.getElementById('link-iv-embed'); let base_url_yt_watch = elem_yt_watch.getAttribute('data-base-url'); let base_url_yt_embed = elem_yt_embed.getAttribute('data-base-url'); + + setTimeout(() => { + elem_yt_watch.setAttribute('href') = addCurrentTimeToURL(base_url_yt_watch); + elem_yt_embed.setAttribute('href') = addCurrentTimeToURL(base_url_yt_embed); + }, 5000); + + // Invidious links + + let elem_iv_embed = document.getElementById('link-iv-embed'); + let elem_iv_other = document.getElementById('link-iv-other'); + let base_url_iv_embed = elem_iv_embed.getAttribute('data-base-url'); + let base_url_iv_other = elem_iv_other.getAttribute('data-base-url'); - setTimeout(() => { elem_yt_watch.setAttribute('href') = addCurrentTimeToURL(base_url_yt_watch); }, 5000); - setTimeout(() => { elem_yt_embed.setAttribute('href') = addCurrentTimeToURL(base_url_yt_embed); }, 5000); - setTimeout(() => { elem_iv_embed.setAttribute('href') = addCurrentTimeToURL(base_url_iv_embed); }, 5000); + setTimeout(() => { + elem_iv_embed.setAttribute('href') = addCurrentTimeToURL(base_url_iv_embed); + elem_iv_other.setAttribute('href') = addCurrentTimeToURL(base_url_iv_other); + }, 5000); }); diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index a768328a..bf297a43 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -127,11 +127,8 @@ we're going to need to do it here in order to allow for translations.

    - <% if env.get("preferences").as(Preferences).automatic_instance_redirect%> - "><%= translate(locale, "Switch Invidious Instance") %> - <% else %> - <%= translate(locale, "Switch Invidious Instance") %> - <% end %> + <%- link_iv_other = IV::Frontend::Misc.redirect_url(env) -%> + <%= translate(locale, "Switch Invidious Instance") %>

    '; +var spinnerHTMLwithHR = spinnerHTML + '
    '; + +String.prototype.supplant = function (o) { + return this.replace(/{([^{}]*)}/g, function (a, b) { + var r = o[b]; + return typeof r === 'string' || typeof r === 'number' ? r : a; + }); +}; + +function toggle_comments(event) { + var target = event.target; + var body = target.parentNode.parentNode.parentNode.children[1]; + if (body.style.display === 'none') { + target.textContent = '[ − ]'; + body.style.display = ''; + } else { + target.textContent = '[ + ]'; + body.style.display = 'none'; + } +} + +function hide_youtube_replies(event) { + var target = event.target; + + var sub_text = target.getAttribute('data-inner-text'); + var inner_text = target.getAttribute('data-sub-text'); + + var body = target.parentNode.parentNode.children[1]; + body.style.display = 'none'; + + target.textContent = sub_text; + target.onclick = show_youtube_replies; + target.setAttribute('data-inner-text', inner_text); + target.setAttribute('data-sub-text', sub_text); +} + +function show_youtube_replies(event) { + var target = event.target; + + var sub_text = target.getAttribute('data-inner-text'); + var inner_text = target.getAttribute('data-sub-text'); + + var body = target.parentNode.parentNode.children[1]; + body.style.display = ''; + + target.textContent = sub_text; + target.onclick = hide_youtube_replies; + target.setAttribute('data-inner-text', inner_text); + target.setAttribute('data-sub-text', sub_text); +} + +function get_youtube_comments() { + var comments = document.getElementById('comments'); + + var fallback = comments.innerHTML; + comments.innerHTML = spinnerHTML; + + var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id + var url = baseUrl + + '?format=html' + + '&hl=' + video_data.preferences.locale + + '&thin_mode=' + video_data.preferences.thin_mode; + + if (video_data.ucid) { + url += '&ucid=' + video_data.ucid + } + + var onNon200 = function (xhr) { comments.innerHTML = fallback; }; + if (video_data.params.comments[1] === 'youtube') + onNon200 = function (xhr) {}; + + helpers.xhr('GET', url, {retries: 5, entity_name: 'comments'}, { + on200: function (response) { + var commentInnerHtml = ' \ +
    \ +

    \ + [ − ] \ + {commentsText} \ +

    \ + \ + ' + if (video_data.support_reddit) { + commentInnerHtml += ' \ + {redditComments} \ + \ + ' + } + commentInnerHtml += ' \ +
    \ +
    {contentHtml}
    \ +
    ' + commentInnerHtml = commentInnerHtml.supplant({ + contentHtml: response.contentHtml, + redditComments: video_data.reddit_comments_text, + commentsText: video_data.comments_text.supplant({ + // toLocaleString correctly splits number with local thousands separator. e.g.: + // '1,234,567.89' for user with English locale + // '1 234 567,89' for user with Russian locale + // '1.234.567,89' for user with Portuguese locale + commentCount: response.commentCount.toLocaleString() + }) + }); + comments.innerHTML = commentInnerHtml; + comments.children[0].children[0].children[0].onclick = toggle_comments; + if (video_data.support_reddit) { + comments.children[0].children[1].children[0].onclick = swap_comments; + } + }, + onNon200: onNon200, // declared above + onError: function (xhr) { + comments.innerHTML = spinnerHTML; + }, + onTimeout: function (xhr) { + comments.innerHTML = spinnerHTML; + } + }); +} + +function get_youtube_replies(target, load_more, load_replies) { + var continuation = target.getAttribute('data-continuation'); + + var body = target.parentNode.parentNode; + var fallback = body.innerHTML; + body.innerHTML = spinnerHTML; + var baseUrl = video_data.base_url || '/api/v1/comments/'+ video_data.id + var url = baseUrl + + '?format=html' + + '&hl=' + video_data.preferences.locale + + '&thin_mode=' + video_data.preferences.thin_mode + + '&continuation=' + continuation; + + if (video_data.ucid) { + url += '&ucid=' + video_data.ucid + } + if (load_replies) url += '&action=action_get_comment_replies'; + + helpers.xhr('GET', url, {}, { + on200: function (response) { + if (load_more) { + body = body.parentNode.parentNode; + body.removeChild(body.lastElementChild); + body.insertAdjacentHTML('beforeend', response.contentHtml); + } else { + body.removeChild(body.lastElementChild); + + var p = document.createElement('p'); + var a = document.createElement('a'); + p.appendChild(a); + + a.href = 'javascript:void(0)'; + a.onclick = hide_youtube_replies; + a.setAttribute('data-sub-text', video_data.hide_replies_text); + a.setAttribute('data-inner-text', video_data.show_replies_text); + a.textContent = video_data.hide_replies_text; + + var div = document.createElement('div'); + div.innerHTML = response.contentHtml; + + body.appendChild(p); + body.appendChild(div); + } + }, + onNon200: function (xhr) { + body.innerHTML = fallback; + }, + onTimeout: function (xhr) { + console.warn('Pulling comments failed'); + body.innerHTML = fallback; + } + }); +} \ No newline at end of file diff --git a/assets/js/post.js b/assets/js/post.js new file mode 100644 index 00000000..fcbc9155 --- /dev/null +++ b/assets/js/post.js @@ -0,0 +1,3 @@ +addEventListener('load', function (e) { + get_youtube_comments(); +}); diff --git a/assets/js/watch.js b/assets/js/watch.js index 36506abd..26ad138f 100644 --- a/assets/js/watch.js +++ b/assets/js/watch.js @@ -1,14 +1,4 @@ 'use strict'; -var video_data = JSON.parse(document.getElementById('video_data').textContent); -var spinnerHTML = '

    '; -var spinnerHTMLwithHR = spinnerHTML + '
    '; - -String.prototype.supplant = function (o) { - return this.replace(/{([^{}]*)}/g, function (a, b) { - var r = o[b]; - return typeof r === 'string' || typeof r === 'number' ? r : a; - }); -}; function toggle_parent(target) { var body = target.parentNode.parentNode.children[1]; @@ -21,18 +11,6 @@ function toggle_parent(target) { } } -function toggle_comments(event) { - var target = event.target; - var body = target.parentNode.parentNode.parentNode.children[1]; - if (body.style.display === 'none') { - target.textContent = '[ − ]'; - body.style.display = ''; - } else { - target.textContent = '[ + ]'; - body.style.display = 'none'; - } -} - function swap_comments(event) { var source = event.target.getAttribute('data-comments'); @@ -43,36 +21,6 @@ function swap_comments(event) { } } -function hide_youtube_replies(event) { - var target = event.target; - - var sub_text = target.getAttribute('data-inner-text'); - var inner_text = target.getAttribute('data-sub-text'); - - var body = target.parentNode.parentNode.children[1]; - body.style.display = 'none'; - - target.textContent = sub_text; - target.onclick = show_youtube_replies; - target.setAttribute('data-inner-text', inner_text); - target.setAttribute('data-sub-text', sub_text); -} - -function show_youtube_replies(event) { - var target = event.target; - - var sub_text = target.getAttribute('data-inner-text'); - var inner_text = target.getAttribute('data-sub-text'); - - var body = target.parentNode.parentNode.children[1]; - body.style.display = ''; - - target.textContent = sub_text; - target.onclick = hide_youtube_replies; - target.setAttribute('data-inner-text', inner_text); - target.setAttribute('data-sub-text', sub_text); -} - var continue_button = document.getElementById('continue'); if (continue_button) { continue_button.onclick = continue_autoplay; @@ -208,111 +156,6 @@ function get_reddit_comments() { }); } -function get_youtube_comments() { - var comments = document.getElementById('comments'); - - var fallback = comments.innerHTML; - comments.innerHTML = spinnerHTML; - - var url = '/api/v1/comments/' + video_data.id + - '?format=html' + - '&hl=' + video_data.preferences.locale + - '&thin_mode=' + video_data.preferences.thin_mode; - - var onNon200 = function (xhr) { comments.innerHTML = fallback; }; - if (video_data.params.comments[1] === 'youtube') - onNon200 = function (xhr) {}; - - helpers.xhr('GET', url, {retries: 5, entity_name: 'comments'}, { - on200: function (response) { - comments.innerHTML = ' \ -
    \ -

    \ - [ − ] \ - {commentsText} \ -

    \ - \ - \ - {redditComments} \ - \ - \ -
    \ -
    {contentHtml}
    \ -
    '.supplant({ - contentHtml: response.contentHtml, - redditComments: video_data.reddit_comments_text, - commentsText: video_data.comments_text.supplant({ - // toLocaleString correctly splits number with local thousands separator. e.g.: - // '1,234,567.89' for user with English locale - // '1 234 567,89' for user with Russian locale - // '1.234.567,89' for user with Portuguese locale - commentCount: response.commentCount.toLocaleString() - }) - }); - - comments.children[0].children[0].children[0].onclick = toggle_comments; - comments.children[0].children[1].children[0].onclick = swap_comments; - }, - onNon200: onNon200, // declared above - onError: function (xhr) { - comments.innerHTML = spinnerHTML; - }, - onTimeout: function (xhr) { - comments.innerHTML = spinnerHTML; - } - }); -} - -function get_youtube_replies(target, load_more, load_replies) { - var continuation = target.getAttribute('data-continuation'); - - var body = target.parentNode.parentNode; - var fallback = body.innerHTML; - body.innerHTML = spinnerHTML; - - var url = '/api/v1/comments/' + video_data.id + - '?format=html' + - '&hl=' + video_data.preferences.locale + - '&thin_mode=' + video_data.preferences.thin_mode + - '&continuation=' + continuation; - if (load_replies) url += '&action=action_get_comment_replies'; - - helpers.xhr('GET', url, {}, { - on200: function (response) { - if (load_more) { - body = body.parentNode.parentNode; - body.removeChild(body.lastElementChild); - body.insertAdjacentHTML('beforeend', response.contentHtml); - } else { - body.removeChild(body.lastElementChild); - - var p = document.createElement('p'); - var a = document.createElement('a'); - p.appendChild(a); - - a.href = 'javascript:void(0)'; - a.onclick = hide_youtube_replies; - a.setAttribute('data-sub-text', video_data.hide_replies_text); - a.setAttribute('data-inner-text', video_data.show_replies_text); - a.textContent = video_data.hide_replies_text; - - var div = document.createElement('div'); - div.innerHTML = response.contentHtml; - - body.appendChild(p); - body.appendChild(div); - } - }, - onNon200: function (xhr) { - body.innerHTML = fallback; - }, - onTimeout: function (xhr) { - console.warn('Pulling comments failed'); - body.innerHTML = fallback; - } - }); -} - if (video_data.play_next) { player.on('ended', function () { var url = new URL('https://example.com/watch?v=' + video_data.next_video); diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 791f1641..85ddff35 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -24,7 +24,35 @@ def fetch_channel_community(ucid, cursor, locale, format, thin_mode) return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode) end -def extract_channel_community(items, *, ucid, locale, format, thin_mode) +def fetch_channel_community_post(ucid, postId, locale, format, thin_mode, params : String | Nil = nil) + if params.nil? + object = { + "2:string" => "community", + "25:embedded" => { + "22:string" => postId.to_s, + }, + "45:embedded" => { + "2:varint" => 1_i64, + "3:varint" => 1_i64, + }, + } + params = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + end + + initial_data = YoutubeAPI.browse(ucid, params: params) + + items = [] of JSON::Any + extract_items(initial_data) do |item| + items << item + end + + return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode, is_single_post: true) +end + +def extract_channel_community(items, *, ucid, locale, format, thin_mode, is_single_post : Bool = false) if message = items[0]["messageRenderer"]? error_message = (message["text"]["simpleText"]? || message["text"]["runs"]?.try &.[0]?.try &.["text"]?) @@ -39,6 +67,9 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode) response = JSON.build do |json| json.object do json.field "authorId", ucid + if is_single_post + json.field "singlePost", true + end json.field "comments" do json.array do items.each do |post| @@ -240,8 +271,10 @@ def extract_channel_community(items, *, ucid, locale, format, thin_mode) end end end - if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token") - json.field "continuation", extract_channel_community_cursor(cont.as_s) + if !is_single_post + if cont = items.dig?(-1, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token") + json.field "continuation", extract_channel_community_cursor(cont.as_s) + end end end end diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr index 1ba1b534..da7f0543 100644 --- a/src/invidious/comments/youtube.cr +++ b/src/invidious/comments/youtube.cr @@ -13,6 +13,51 @@ module Invidious::Comments client_config = YoutubeAPI::ClientConfig.new(region: region) response = YoutubeAPI.next(continuation: ctoken, client_config: client_config) + return parse_youtube(id, response, format, locale, thin_mode, sort_by) + end + + def fetch_community_post_comments(ucid, postId) + object = { + "2:string" => "community", + "25:embedded" => { + "22:string" => postId, + }, + "45:embedded" => { + "2:varint" => 1_i64, + "3:varint" => 1_i64, + }, + "53:embedded" => { + "4:embedded" => { + "6:varint" => 0_i64, + "27:varint" => 1_i64, + "29:string" => postId, + "30:string" => ucid, + }, + "8:string" => "comments-section", + }, + } + + objectParsed = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + + object2 = { + "80226972:embedded" => { + "2:string" => ucid, + "3:string" => objectParsed, + }, + } + + continuation = object2.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + initial_data = YoutubeAPI.browse(continuation: continuation) + return initial_data + end + + def parse_youtube(id, response, format, locale, thin_mode, sort_by = "top", isPost = false) contents = nil if on_response_received_endpoints = response["onResponseReceivedEndpoints"]? @@ -68,7 +113,11 @@ module Invidious::Comments json.field "commentCount", comment_count end - json.field "videoId", id + if isPost + json.field "postId", id + else + json.field "videoId", id + end json.field "comments" do json.array do diff --git a/src/invidious/frontend/comments_youtube.cr b/src/invidious/frontend/comments_youtube.cr index 41f43f04..ecc0bc1b 100644 --- a/src/invidious/frontend/comments_youtube.cr +++ b/src/invidious/frontend/comments_youtube.cr @@ -23,6 +23,24 @@ module Invidious::Frontend::Comments
    END_HTML + elsif comments["authorId"]? && !comments["singlePost"]? + # for posts we should display a link to the post + replies_count_text = translate_count(locale, + "comments_view_x_replies", + child["replyCount"].as_i64 || 0, + NumberFormatting::Separator + ) + + replies_html = <<-END_HTML +
    +
    + +
    + END_HTML end if !thin_mode diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index adf05d30..0d2d2eb1 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -343,6 +343,53 @@ module Invidious::Routes::API::V1::Channels end end + def self.post(env) + locale = env.get("preferences").as(Preferences).locale + + env.response.content_type = "application/json" + + id = env.params.url["id"].to_s + ucid = env.params.query["ucid"] + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + begin + fetch_channel_community_post(ucid, id, locale, format, thin_mode) + rescue ex + return error_json(500, ex) + end + end + + def self.post_comments(env) + locale = env.get("preferences").as(Preferences).locale + region = env.params.query["region"]? + + env.response.content_type = "application/json" + + id = env.params.url["id"] + + thin_mode = env.params.query["thin_mode"]? + thin_mode = thin_mode == "true" + + format = env.params.query["format"]? + format ||= "json" + + continuation = env.params.query["continuation"]? + + case continuation + when nil, "" + ucid = env.params.query["ucid"] + comments = Comments.fetch_community_post_comments(ucid, id) + else + comments = YoutubeAPI.browse(continuation: continuation) + end + return Comments.parse_youtube(id, comments, format, locale, thin_mode, isPost: true) + end + def self.channels(env) locale = env.get("preferences").as(Preferences).locale ucid = env.params.url["ucid"] diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index e499f4d6..91a62fa3 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -162,17 +162,23 @@ module Invidious::Routes::API::V1::Misc resolved_url = YoutubeAPI.resolve_url(url.as(String)) endpoint = resolved_url["endpoint"] pageType = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" - if resolved_ucid = endpoint.dig?("watchEndpoint", "videoId") - elsif resolved_ucid = endpoint.dig?("browseEndpoint", "browseId") + if sub_endpoint = endpoint.dig?("watchEndpoint") + resolved_ucid = sub_endpoint.dig?("videoId") + elsif sub_endpoint = endpoint.dig?("browseEndpoint") + resolved_ucid = sub_endpoint.dig?("browseId") elsif pageType == "WEB_PAGE_TYPE_UNKNOWN" return error_json(400, "Unknown url") end + if !sub_endpoint.nil? + params = sub_endpoint.dig?("params") + end rescue ex return error_json(500, ex) end JSON.build do |json| json.object do json.field "ucid", resolved_ucid.try &.as_s || "" + json.field "params", params.try &.as_s json.field "pageType", pageType end end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 9892ae2a..1d02ee08 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -159,6 +159,11 @@ module Invidious::Routes::Channels end locale, user, subscriptions, continuation, ucid, channel = data + # redirect to post page + if lb = env.params.query["lb"]? + env.redirect "/post/#{lb}?ucid=#{ucid}" + end + thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode thin_mode = thin_mode == "true" @@ -187,6 +192,38 @@ module Invidious::Routes::Channels templated "community" end + def self.post(env) + # /post/{postId} + id = env.params.url["id"] + ucid = env.params.query["ucid"]? + + prefs = env.get("preferences").as(Preferences) + + locale = prefs.locale + region = env.params.query["region"]? || prefs.region + + thin_mode = env.params.query["thin_mode"]? || prefs.thin_mode + thin_mode = thin_mode == "true" + + client_config = YoutubeAPI::ClientConfig.new(region: region) + + if !ucid.nil? + ucid = ucid.to_s + post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) + else + # resolve the url to get the author's UCID + response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") + return error_template(400, "Invalid post ID") if response["error"]? + + ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s + params = response.dig("endpoint", "browseEndpoint", "params").as_s + post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode, params: params) + end + + post_response = JSON.parse(post_response) + templated "post" + end + def self.channels(env) data = self.fetch_basic_information(env) return data if !data.is_a?(Tuple) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 9c43171c..8cb49249 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -127,6 +127,7 @@ module Invidious::Routing get "/channel/:ucid/live", Routes::Channels, :live get "/user/:user/live", Routes::Channels, :live get "/c/:user/live", Routes::Channels, :live + get "/post/:id", Routes::Channels, :post {"", "/videos", "/shorts", "/streams", "/playlists", "/community", "/about"}.each do |path| # /c/LinusTechTips @@ -240,6 +241,10 @@ module Invidious::Routing get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} {% end %} + # Posts + get "/api/v1/post/:id", {{namespace}}::Channels, :post + get "/api/v1/post/:id/comments", {{namespace}}::Channels, :post_comments + # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect @@ -249,6 +254,7 @@ module Invidious::Routing get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions get "/api/v1/hashtag/:hashtag", {{namespace}}::Search, :hashtag + # Authenticated # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 24efc34e..d2a305d3 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -26,7 +26,7 @@

    <%= error_message %>

    <% else %> -
    +
    <%= IV::Frontend::Comments.template_youtube(items.not_nil!, locale, thin_mode) %>
    <% end %> diff --git a/src/invidious/views/post.ecr b/src/invidious/views/post.ecr new file mode 100644 index 00000000..b2cd778c --- /dev/null +++ b/src/invidious/views/post.ecr @@ -0,0 +1,31 @@ +<% content_for "header" do %> +Invidious +<% end %> + +
    + <%= IV::Frontend::Comments.template_youtube(post_response.not_nil!, locale, thin_mode) %> +
    +
    +
    + + + + \ No newline at end of file diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 498d57a1..62a154a4 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -64,7 +64,8 @@ we're going to need to do it here in order to allow for translations. "premiere_timestamp" => video.premiere_timestamp.try &.to_unix, "vr" => video.is_vr, "projection_type" => video.projection_type, - "local_disabled" => CONFIG.disabled?("local") + "local_disabled" => CONFIG.disabled?("local"), + "support_reddit" => true }.to_pretty_json %> @@ -270,7 +271,7 @@ we're going to need to do it here in order to allow for translations.
    <% end %> -
    +
    <% if nojs %> <%= comment_html %> <% else %> @@ -352,4 +353,5 @@ we're going to need to do it here in order to allow for translations.
    <% end %>
    + -- cgit v1.2.3 From 734f1b7764598bd5ff24acd11ab833f831d0f4a7 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Thu, 27 Jul 2023 19:14:34 -0400 Subject: Simplify resolveUrl api call Co-Authored-By: Samantaz Fox --- src/invidious/channels/community.cr | 4 ++-- src/invidious/comments/youtube.cr | 6 +++--- src/invidious/routes/api/v1/misc.cr | 13 ++++++------- src/invidious/routes/channels.cr | 2 +- 4 files changed, 12 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 85ddff35..76dab361 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -24,12 +24,12 @@ def fetch_channel_community(ucid, cursor, locale, format, thin_mode) return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode) end -def fetch_channel_community_post(ucid, postId, locale, format, thin_mode, params : String | Nil = nil) +def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode, params : String | Nil = nil) if params.nil? object = { "2:string" => "community", "25:embedded" => { - "22:string" => postId.to_s, + "22:string" => post_id.to_s, }, "45:embedded" => { "2:varint" => 1_i64, diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr index da7f0543..01c2564f 100644 --- a/src/invidious/comments/youtube.cr +++ b/src/invidious/comments/youtube.cr @@ -16,11 +16,11 @@ module Invidious::Comments return parse_youtube(id, response, format, locale, thin_mode, sort_by) end - def fetch_community_post_comments(ucid, postId) + def fetch_community_post_comments(ucid, post_id) object = { "2:string" => "community", "25:embedded" => { - "22:string" => postId, + "22:string" => post_id, }, "45:embedded" => { "2:varint" => 1_i64, @@ -30,7 +30,7 @@ module Invidious::Comments "4:embedded" => { "6:varint" => 0_i64, "27:varint" => 1_i64, - "29:string" => postId, + "29:string" => post_id, "30:string" => ucid, }, "8:string" => "comments-section", diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 91a62fa3..6118a0d1 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -162,16 +162,15 @@ module Invidious::Routes::API::V1::Misc resolved_url = YoutubeAPI.resolve_url(url.as(String)) endpoint = resolved_url["endpoint"] pageType = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" - if sub_endpoint = endpoint.dig?("watchEndpoint") - resolved_ucid = sub_endpoint.dig?("videoId") - elsif sub_endpoint = endpoint.dig?("browseEndpoint") - resolved_ucid = sub_endpoint.dig?("browseId") + if sub_endpoint = endpoint["watchEndpoint"]? + resolved_ucid = sub_endpoint["videoId"]? + elsif sub_endpoint = endpoint["browseEndpoint"]? + resolved_ucid = sub_endpoint["browseId"]? elsif pageType == "WEB_PAGE_TYPE_UNKNOWN" return error_json(400, "Unknown url") end - if !sub_endpoint.nil? - params = sub_endpoint.dig?("params") - end + + params = sub_endpoint.try &.dig?("params") rescue ex return error_json(500, ex) end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 1d02ee08..8515b910 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -161,7 +161,7 @@ module Invidious::Routes::Channels # redirect to post page if lb = env.params.query["lb"]? - env.redirect "/post/#{lb}?ucid=#{ucid}" + env.redirect "/post/#{URI.encode_www_form(lb)}?ucid=#{URI.encode_www_form(ucid)}" end thin_mode = env.params.query["thin_mode"]? || env.get("preferences").as(Preferences).thin_mode -- cgit v1.2.3 From f55b96a53bde8d8c6a24d4db4e9d10f14ffee585 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Mon, 7 Aug 2023 12:46:19 -0700 Subject: Always craft Community Post params --- src/invidious/channels/community.cr | 32 +++++++++++++++----------------- src/invidious/routes/channels.cr | 3 +-- 2 files changed, 16 insertions(+), 19 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 76dab361..49ffd990 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -24,23 +24,21 @@ def fetch_channel_community(ucid, cursor, locale, format, thin_mode) return extract_channel_community(items, ucid: ucid, locale: locale, format: format, thin_mode: thin_mode) end -def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode, params : String | Nil = nil) - if params.nil? - object = { - "2:string" => "community", - "25:embedded" => { - "22:string" => post_id.to_s, - }, - "45:embedded" => { - "2:varint" => 1_i64, - "3:varint" => 1_i64, - }, - } - params = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - end +def fetch_channel_community_post(ucid, post_id, locale, format, thin_mode) + object = { + "2:string" => "community", + "25:embedded" => { + "22:string" => post_id.to_s, + }, + "45:embedded" => { + "2:varint" => 1_i64, + "3:varint" => 1_i64, + }, + } + params = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } initial_data = YoutubeAPI.browse(ucid, params: params) diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 8515b910..20b02dc1 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -216,8 +216,7 @@ module Invidious::Routes::Channels return error_template(400, "Invalid post ID") if response["error"]? ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s - params = response.dig("endpoint", "browseEndpoint", "params").as_s - post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode, params: params) + post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) end post_response = JSON.parse(post_response) -- cgit v1.2.3 From bb04bcc42c1b135aaf50de8799264f86bc42f4db Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 29 Aug 2023 19:10:01 -0700 Subject: Apply suggestions from code review add videoId to resolve_url function Co-Authored-By: Samantaz Fox --- src/invidious/comments/youtube.cr | 4 ++-- src/invidious/routes/api/v1/channels.cr | 11 +++++++++-- src/invidious/routes/api/v1/misc.cr | 10 ++++------ src/invidious/routes/channels.cr | 2 -- src/invidious/views/post.ecr | 2 +- 5 files changed, 16 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/invidious/comments/youtube.cr b/src/invidious/comments/youtube.cr index 01c2564f..185d8e43 100644 --- a/src/invidious/comments/youtube.cr +++ b/src/invidious/comments/youtube.cr @@ -37,14 +37,14 @@ module Invidious::Comments }, } - objectParsed = object.try { |i| Protodec::Any.cast_json(i) } + object_parsed = object.try { |i| Protodec::Any.cast_json(i) } .try { |i| Protodec::Any.from_json(i) } .try { |i| Base64.urlsafe_encode(i) } object2 = { "80226972:embedded" => { "2:string" => ucid, - "3:string" => objectParsed, + "3:string" => object_parsed, }, } diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 0d2d2eb1..a5ae16a8 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -347,9 +347,8 @@ module Invidious::Routes::API::V1::Channels locale = env.get("preferences").as(Preferences).locale env.response.content_type = "application/json" - id = env.params.url["id"].to_s - ucid = env.params.query["ucid"] + ucid = env.params.query["ucid"]? thin_mode = env.params.query["thin_mode"]? thin_mode = thin_mode == "true" @@ -357,6 +356,14 @@ module Invidious::Routes::API::V1::Channels format = env.params.query["format"]? format ||= "json" + if ucid.nil? + response = YoutubeAPI.resolve_url("https://www.youtube.com/post/#{id}") + return error_json(400, "Invalid post ID") if response["error"]? + ucid = response.dig("endpoint", "browseEndpoint", "browseId").as_s + else + ucid = ucid.to_s + end + begin fetch_channel_community_post(ucid, id, locale, format, thin_mode) rescue ex diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 6118a0d1..5dfc4afa 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -162,21 +162,19 @@ module Invidious::Routes::API::V1::Misc resolved_url = YoutubeAPI.resolve_url(url.as(String)) endpoint = resolved_url["endpoint"] pageType = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" - if sub_endpoint = endpoint["watchEndpoint"]? - resolved_ucid = sub_endpoint["videoId"]? - elsif sub_endpoint = endpoint["browseEndpoint"]? - resolved_ucid = sub_endpoint["browseId"]? - elsif pageType == "WEB_PAGE_TYPE_UNKNOWN" + if pageType == "WEB_PAGE_TYPE_UNKNOWN" return error_json(400, "Unknown url") end + sub_endpoint = endpoint["watchEndpoint"]? || endpoint["browseEndpoint"]? || endpoint params = sub_endpoint.try &.dig?("params") rescue ex return error_json(500, ex) end JSON.build do |json| json.object do - json.field "ucid", resolved_ucid.try &.as_s || "" + json.field "ucid", sub_endpoint["browseId"].try &.as_s if sub_endpoint["browseId"]? + json.field "videoId", sub_endpoint["videoId"].try &.as_s if sub_endpoint["videoId"]? json.field "params", params.try &.as_s json.field "pageType", pageType end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 20b02dc1..29995bf6 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -205,8 +205,6 @@ module Invidious::Routes::Channels thin_mode = env.params.query["thin_mode"]? || prefs.thin_mode thin_mode = thin_mode == "true" - client_config = YoutubeAPI::ClientConfig.new(region: region) - if !ucid.nil? ucid = ucid.to_s post_response = fetch_channel_community_post(ucid, id, locale, "json", thin_mode) diff --git a/src/invidious/views/post.ecr b/src/invidious/views/post.ecr index b2cd778c..071d1c88 100644 --- a/src/invidious/views/post.ecr +++ b/src/invidious/views/post.ecr @@ -22,7 +22,7 @@ "comments": ["youtube"] }, "preferences" => prefs, - "base_url" => "/api/v1/post/" + id + "/comments", + "base_url" => "/api/v1/post/#{URI.encode_www_form(id)}/comments", "ucid" => ucid }.to_pretty_json %> -- cgit v1.2.3 From 8781520b8af221e5ab202775a1b58dd5e0e3fd83 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 18 Jul 2023 08:06:50 -0700 Subject: Search: Parse channel handle and hide video count when channel handle exists Co-Authored-By: Samantaz Fox --- src/invidious/helpers/serialized_yt_data.cr | 2 ++ src/invidious/views/components/item.ecr | 3 ++- src/invidious/yt_backend/extractors.cr | 10 ++++++---- 3 files changed, 10 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index e0bd7279..31a3cf44 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -186,6 +186,7 @@ struct SearchChannel property author_thumbnail : String property subscriber_count : Int32 property video_count : Int32 + property channel_handle : String? property description_html : String property auto_generated : Bool property author_verified : Bool @@ -214,6 +215,7 @@ struct SearchChannel json.field "autoGenerated", self.auto_generated json.field "subCount", self.subscriber_count json.field "videoCount", self.video_count + json.field "channelHandle", self.channel_handle json.field "description", html_to_content(self.description_html) json.field "descriptionHtml", self.description_html diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index c29ec47b..031b46da 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -26,8 +26,9 @@
    + <% if !item.channel_handle.nil? %>

    <%= item.channel_handle %>

    <% end %>

    <%= translate_count(locale, "generic_subscribers_count", item.subscriber_count, NumberFormatting::Separator) %>

    - <% if !item.auto_generated %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> + <% if !item.auto_generated && item.channel_handle.nil? %>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %>
    <%= item.description_html %>
    <% when SearchHashtag %> <% if !thin_mode %> diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index aaf7772e..56325cf7 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -175,17 +175,18 @@ private module Parsers # Always simpleText # TODO change default value to nil - subscriber_count = item_contents.dig?("subscriberCountText", "simpleText") + subscriber_count = item_contents.dig?("subscriberCountText", "simpleText").try &.as_s + channel_handle = subscriber_count if (subscriber_count.try &.starts_with? "@") # Since youtube added channel handles, `VideoCountText` holds the number of # subscribers and `subscriberCountText` holds the handle, except when the # channel doesn't have a handle (e.g: some topic music channels). # See https://github.com/iv-org/invidious/issues/3394#issuecomment-1321261688 - if !subscriber_count || !subscriber_count.as_s.includes? " subscriber" - subscriber_count = item_contents.dig?("videoCountText", "simpleText") + if !subscriber_count || !subscriber_count.includes? " subscriber" + subscriber_count = item_contents.dig?("videoCountText", "simpleText").try &.as_s end subscriber_count = subscriber_count - .try { |s| short_text_to_number(s.as_s.split(" ")[0]).to_i32 } || 0 + .try { |s| short_text_to_number(s.split(" ")[0]).to_i32 } || 0 # Auto-generated channels doesn't have videoCountText # Taken from: https://github.com/iv-org/invidious/pull/2228#discussion_r717620922 @@ -200,6 +201,7 @@ private module Parsers author_thumbnail: author_thumbnail, subscriber_count: subscriber_count, video_count: video_count, + channel_handle: channel_handle, description_html: description_html, auto_generated: auto_generated, author_verified: author_verified, -- cgit v1.2.3 From 54fa59cbb0ae90a54136522c944410e2d18c234b Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 24 Aug 2023 14:58:50 -0700 Subject: Add method to construct WebVTT files Similar to JSON.Build --- spec/helpers/vtt/builder_spec.cr | 64 ++++++++++++++++++++++++++++++++++++++ src/invidious/helpers/webvtt.cr | 67 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 spec/helpers/vtt/builder_spec.cr create mode 100644 src/invidious/helpers/webvtt.cr (limited to 'src') diff --git a/spec/helpers/vtt/builder_spec.cr b/spec/helpers/vtt/builder_spec.cr new file mode 100644 index 00000000..69303bab --- /dev/null +++ b/spec/helpers/vtt/builder_spec.cr @@ -0,0 +1,64 @@ +require "../../spec_helper.cr" + +MockLines = [ + { + "start_time": Time::Span.new(seconds: 1), + "end_time": Time::Span.new(seconds: 2), + "text": "Line 1", + }, + + { + "start_time": Time::Span.new(seconds: 2), + "end_time": Time::Span.new(seconds: 3), + "text": "Line 2", + }, +] + +Spectator.describe "WebVTT::Builder" do + it "correctly builds a vtt file" do + result = WebVTT.build do |vtt| + MockLines.each do |line| + vtt.line(line["start_time"], line["end_time"], line["text"]) + end + end + + expect(result).to eq([ + "WEBVTT", + "", + "00:00:01.000 --> 00:00:02.000", + "Line 1", + "", + "00:00:02.000 --> 00:00:03.000", + "Line 2", + "", + "", + ].join('\n')) + end + + it "correctly builds a vtt file with setting fields" do + setting_fields = { + "Kind" => "captions", + "Language" => "en", + } + + result = WebVTT.build(setting_fields) do |vtt| + MockLines.each do |line| + vtt.line(line["start_time"], line["end_time"], line["text"]) + end + end + + expect(result).to eq([ + "WEBVTT", + "Kind: captions", + "Language: en", + "", + "00:00:01.000 --> 00:00:02.000", + "Line 1", + "", + "00:00:02.000 --> 00:00:03.000", + "Line 2", + "", + "", + ].join('\n')) + end +end diff --git a/src/invidious/helpers/webvtt.cr b/src/invidious/helpers/webvtt.cr new file mode 100644 index 00000000..7d9d5f1f --- /dev/null +++ b/src/invidious/helpers/webvtt.cr @@ -0,0 +1,67 @@ +# Namespace for logic relating to generating WebVTT files +# +# Probably not compliant to WebVTT's specs but it is enough for Invidious. +module WebVTT + # A WebVTT builder generates WebVTT files + private class Builder + def initialize(@io : IO) + end + + # Writes an vtt line with the specified time stamp and contents + def line(start_time : Time::Span, end_time : Time::Span, text : String) + timestamp(start_time, end_time) + @io << text + @io << "\n\n" + end + + private def timestamp(start_time : Time::Span, end_time : Time::Span) + add_timestamp_component(start_time) + @io << " --> " + add_timestamp_component(end_time) + + @io << '\n' + end + + private def add_timestamp_component(timestamp : Time::Span) + @io << timestamp.hours.to_s.rjust(2, '0') + @io << ':' << timestamp.minutes.to_s.rjust(2, '0') + @io << ':' << timestamp.seconds.to_s.rjust(2, '0') + @io << '.' << timestamp.milliseconds.to_s.rjust(3, '0') + end + + def document(setting_fields : Hash(String, String)? = nil, &) + @io << "WEBVTT\n" + + if setting_fields + setting_fields.each do |name, value| + @io << "#{name}: #{value}\n" + end + end + + @io << '\n' + + yield + end + end + + # Returns the resulting `String` of writing WebVTT to the yielded WebVTT::Builder + # + # ``` + # string = WebVTT.build do |io| + # vtt.line(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1") + # vtt.line(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2") + # end + # + # string # => "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nLine 1\n\n00:00:02.000 --> 00:00:03.000\nLine 2\n\n" + # ``` + # + # Accepts an optional settings fields hash to add settings attribute to the resulting vtt file. + def self.build(setting_fields : Hash(String, String)? = nil, &) + String.build do |str| + builder = Builder.new(str) + builder.document(setting_fields) do + yield builder + end + end + end +end -- cgit v1.2.3 From 0cb7d0b44137c2cee9b6352969a28dac4e3576c5 Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 24 Aug 2023 15:10:50 -0700 Subject: Refactor Invidious's VTT logic to use WebVtt.build --- src/invidious/routes/api/v1/videos.cr | 39 +++++++++------------------------ src/invidious/videos/caption.cr | 41 ++++++++++------------------------- src/invidious/videos/transcript.cr | 40 ++++++---------------------------- 3 files changed, 29 insertions(+), 91 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 25e766d2..5c50a804 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -101,20 +101,17 @@ module Invidious::Routes::API::V1::Videos if caption.name.includes? "auto-generated" caption_xml = YT_POOL.client &.get(url).body + settings_field = { + "Kind" => "captions", + "Language" => "#{tlang || caption.language_code}", + } + if caption_xml.starts_with?("/, "") text = text.gsub(/<\/font>/, "") @@ -137,12 +131,7 @@ module Invidious::Routes::API::V1::Videos text = "#{md["text"]}" end - str << <<-END_CUE - #{start_time} --> #{end_time} - #{text} - - - END_CUE + webvtt.line(start_time, end_time, text) end end end @@ -215,11 +204,7 @@ module Invidious::Routes::API::V1::Videos storyboard = storyboard[0] end - String.build do |str| - str << <<-END_VTT - WEBVTT - END_VTT - + WebVTT.build do |vtt| start_time = 0.milliseconds end_time = storyboard[:interval].milliseconds @@ -231,12 +216,8 @@ module Invidious::Routes::API::V1::Videos storyboard[:storyboard_height].times do |j| storyboard[:storyboard_width].times do |k| - str << <<-END_CUE - #{start_time}.000 --> #{end_time}.000 - #{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]} - - - END_CUE + current_cue_url = "#{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}" + vtt.line(start_time, end_time, current_cue_url) start_time += storyboard[:interval].milliseconds end_time += storyboard[:interval].milliseconds diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 256dfcc0..dc58f9a0 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -52,17 +52,13 @@ module Invidious::Videos break end end - result = String.build do |result| - result << <<-END_VTT - WEBVTT - Kind: captions - Language: #{tlang || @language_code} + settings_field = { + "Kind" => "captions", + "Language" => "#{tlang || @language_code}", + } - END_VTT - - result << "\n\n" - + result = WebVTT.build(settings_field) do |vtt| cues.each_with_index do |node, i| start_time = node["t"].to_f.milliseconds @@ -76,29 +72,16 @@ module Invidious::Videos end_time = start_time + duration end - # start_time - result << start_time.hours.to_s.rjust(2, '0') - result << ':' << start_time.minutes.to_s.rjust(2, '0') - result << ':' << start_time.seconds.to_s.rjust(2, '0') - result << '.' << start_time.milliseconds.to_s.rjust(3, '0') - - result << " --> " - - # end_time - result << end_time.hours.to_s.rjust(2, '0') - result << ':' << end_time.minutes.to_s.rjust(2, '0') - result << ':' << end_time.seconds.to_s.rjust(2, '0') - result << '.' << end_time.milliseconds.to_s.rjust(3, '0') - - result << "\n" - - node.children.each do |s| - result << s.content + text = String.build do |io| + node.children.each do |s| + io << s.content + end end - result << "\n" - result << "\n" + + vtt.line(start_time, end_time, text) end end + return result end end diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index f3360a52..cd97cfde 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -34,41 +34,15 @@ module Invidious::Videos # Convert into array of TranscriptLine lines = self.parse(initial_data) - # Taken from Invidious::Videos::Captions::Metadata.timedtext_to_vtt() - vtt = String.build do |vtt| - vtt << <<-END_VTT - WEBVTT - Kind: captions - Language: #{target_language} - - - END_VTT - - vtt << "\n\n" + settings_field = { + "Kind" => "captions", + "Language" => target_language + } + # Taken from Invidious::Videos::Captions::Metadata.timedtext_to_vtt() + vtt = WebVTT.build(settings_field) do |vtt| lines.each do |line| - start_time = line.start_ms - end_time = line.end_ms - - # start_time - vtt << start_time.hours.to_s.rjust(2, '0') - vtt << ':' << start_time.minutes.to_s.rjust(2, '0') - vtt << ':' << start_time.seconds.to_s.rjust(2, '0') - vtt << '.' << start_time.milliseconds.to_s.rjust(3, '0') - - vtt << " --> " - - # end_time - vtt << end_time.hours.to_s.rjust(2, '0') - vtt << ':' << end_time.minutes.to_s.rjust(2, '0') - vtt << ':' << end_time.seconds.to_s.rjust(2, '0') - vtt << '.' << end_time.milliseconds.to_s.rjust(3, '0') - - vtt << "\n" - vtt << line.line - - vtt << "\n" - vtt << "\n" + vtt.line(line.start_ms, line.end_ms, line.line) end end -- cgit v1.2.3 From d371eb50f27b9d29bc68ec883d8bee54894c79a4 Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 24 Aug 2023 15:42:42 -0700 Subject: WebVTT::Builder: rename #line to #cue --- spec/helpers/vtt/builder_spec.cr | 4 ++-- src/invidious/helpers/webvtt.cr | 8 ++++---- src/invidious/routes/api/v1/videos.cr | 4 ++-- src/invidious/videos/caption.cr | 2 +- src/invidious/videos/transcript.cr | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) (limited to 'src') diff --git a/spec/helpers/vtt/builder_spec.cr b/spec/helpers/vtt/builder_spec.cr index 69303bab..7b543ddc 100644 --- a/spec/helpers/vtt/builder_spec.cr +++ b/spec/helpers/vtt/builder_spec.cr @@ -18,7 +18,7 @@ Spectator.describe "WebVTT::Builder" do it "correctly builds a vtt file" do result = WebVTT.build do |vtt| MockLines.each do |line| - vtt.line(line["start_time"], line["end_time"], line["text"]) + vtt.cue(line["start_time"], line["end_time"], line["text"]) end end @@ -43,7 +43,7 @@ Spectator.describe "WebVTT::Builder" do result = WebVTT.build(setting_fields) do |vtt| MockLines.each do |line| - vtt.line(line["start_time"], line["end_time"], line["text"]) + vtt.cue(line["start_time"], line["end_time"], line["text"]) end end diff --git a/src/invidious/helpers/webvtt.cr b/src/invidious/helpers/webvtt.cr index 7d9d5f1f..c50d7fa2 100644 --- a/src/invidious/helpers/webvtt.cr +++ b/src/invidious/helpers/webvtt.cr @@ -7,8 +7,8 @@ module WebVTT def initialize(@io : IO) end - # Writes an vtt line with the specified time stamp and contents - def line(start_time : Time::Span, end_time : Time::Span, text : String) + # Writes an vtt cue with the specified time stamp and contents + def cue(start_time : Time::Span, end_time : Time::Span, text : String) timestamp(start_time, end_time) @io << text @io << "\n\n" @@ -48,8 +48,8 @@ module WebVTT # # ``` # string = WebVTT.build do |io| - # vtt.line(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1") - # vtt.line(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2") + # vtt.cue(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1") + # vtt.cue(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2") # end # # string # => "WEBVTT\n\n00:00:01.000 --> 00:00:02.000\nLine 1\n\n00:00:02.000 --> 00:00:03.000\nLine 2\n\n" diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 5c50a804..449c9f9b 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -131,7 +131,7 @@ module Invidious::Routes::API::V1::Videos text = "#{md["text"]}" end - webvtt.line(start_time, end_time, text) + webvtt.cue(start_time, end_time, text) end end end @@ -217,7 +217,7 @@ module Invidious::Routes::API::V1::Videos storyboard[:storyboard_height].times do |j| storyboard[:storyboard_width].times do |k| current_cue_url = "#{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}" - vtt.line(start_time, end_time, current_cue_url) + vtt.cue(start_time, end_time, current_cue_url) start_time += storyboard[:interval].milliseconds end_time += storyboard[:interval].milliseconds diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index dc58f9a0..484e61d2 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -78,7 +78,7 @@ module Invidious::Videos end end - vtt.line(start_time, end_time, text) + vtt.cue(start_time, end_time, text) end end diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index cd97cfde..055d96fb 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -42,7 +42,7 @@ module Invidious::Videos # Taken from Invidious::Videos::Captions::Metadata.timedtext_to_vtt() vtt = WebVTT.build(settings_field) do |vtt| lines.each do |line| - vtt.line(line.start_ms, line.end_ms, line.line) + vtt.cue(line.start_ms, line.end_ms, line.line) end end -- cgit v1.2.3 From 4e97d8ad0942bd64a23ed4a2ba89e48a97c520aa Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 24 Aug 2023 16:27:06 -0700 Subject: Update documentation for `WebVTT.build` --- src/invidious/helpers/webvtt.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/webvtt.cr b/src/invidious/helpers/webvtt.cr index c50d7fa2..52138854 100644 --- a/src/invidious/helpers/webvtt.cr +++ b/src/invidious/helpers/webvtt.cr @@ -44,10 +44,10 @@ module WebVTT end end - # Returns the resulting `String` of writing WebVTT to the yielded WebVTT::Builder + # Returns the resulting `String` of writing WebVTT to the yielded `WebVTT::Builder` # # ``` - # string = WebVTT.build do |io| + # string = WebVTT.build do |vtt| # vtt.cue(Time::Span.new(seconds: 1), Time::Span.new(seconds: 2), "Line 1") # vtt.cue(Time::Span.new(seconds: 2), Time::Span.new(seconds: 3), "Line 2") # end -- cgit v1.2.3 From e9d59a6dfd14fd115f3bfc59ca6f33182a631575 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Tue, 29 Aug 2023 05:59:08 +0000 Subject: Update src/invidious/helpers/webvtt.cr Co-authored-by: Samantaz Fox --- src/invidious/helpers/webvtt.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/webvtt.cr b/src/invidious/helpers/webvtt.cr index 52138854..aace6bb8 100644 --- a/src/invidious/helpers/webvtt.cr +++ b/src/invidious/helpers/webvtt.cr @@ -34,7 +34,7 @@ module WebVTT if setting_fields setting_fields.each do |name, value| - @io << "#{name}: #{value}\n" + @io << name << ": " << value << '\n' end end -- cgit v1.2.3 From a999438ae46739477a6ca5f8515fa70b6b492443 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 28 Aug 2023 23:14:25 -0700 Subject: Consistency: rename #add_timestamp_component Removes the add_ prefix for consistency with the other methods in WebVTT::Builder --- src/invidious/helpers/webvtt.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/webvtt.cr b/src/invidious/helpers/webvtt.cr index aace6bb8..56f761ed 100644 --- a/src/invidious/helpers/webvtt.cr +++ b/src/invidious/helpers/webvtt.cr @@ -15,14 +15,14 @@ module WebVTT end private def timestamp(start_time : Time::Span, end_time : Time::Span) - add_timestamp_component(start_time) + timestamp_component(start_time) @io << " --> " - add_timestamp_component(end_time) + timestamp_component(end_time) @io << '\n' end - private def add_timestamp_component(timestamp : Time::Span) + private def timestamp_component(timestamp : Time::Span) @io << timestamp.hours.to_s.rjust(2, '0') @io << ':' << timestamp.minutes.to_s.rjust(2, '0') @io << ':' << timestamp.seconds.to_s.rjust(2, '0') -- cgit v1.2.3 From be2feba17c2f3b9d8e043825beff57568df46f2e Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 23 Sep 2023 09:57:26 -0400 Subject: Lint --- src/invidious/videos/transcript.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index 055d96fb..dac00eea 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -35,8 +35,8 @@ module Invidious::Videos lines = self.parse(initial_data) settings_field = { - "Kind" => "captions", - "Language" => target_language + "Kind" => "captions", + "Language" => target_language, } # Taken from Invidious::Videos::Captions::Metadata.timedtext_to_vtt() -- cgit v1.2.3 From bf470704a5a3071cebb1d558efaef8542a16dde6 Mon Sep 17 00:00:00 2001 From: Thomas Lange Date: Tue, 26 Sep 2023 21:45:52 +0200 Subject: Add option to control preloading of video data Fix #4110 by adding an option to control the preloading of video data on page load. If disabled ("false"), the browser will not preload any video data until the user explicitly hits the "Play" button. If enabled ("true"), the default behavior will be used, which means the browser decides how much of the video will be preloaded. --- assets/js/player.js | 5 ++++- config/config.example.yml | 14 ++++++++++++++ locales/de.json | 1 + locales/en-US.json | 1 + src/invidious/config.cr | 1 + src/invidious/routes/preferences.cr | 5 +++++ src/invidious/user/preferences.cr | 1 + src/invidious/videos/video_preferences.cr | 6 ++++++ src/invidious/views/components/player.ecr | 4 +++- src/invidious/views/user/preferences.ecr | 5 +++++ 10 files changed, 41 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/assets/js/player.js b/assets/js/player.js index bb53ac24..398c66f8 100644 --- a/assets/js/player.js +++ b/assets/js/player.js @@ -3,7 +3,6 @@ var player_data = JSON.parse(document.getElementById('player_data').textContent) var video_data = JSON.parse(document.getElementById('video_data').textContent); var options = { - preload: 'auto', liveui: true, playbackRates: [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0], controlBar: { @@ -35,6 +34,10 @@ if (player_data.aspect_ratio) { options.aspectRatio = player_data.aspect_ratio; } +if (player_data.preload) { + options.preload = player_data.preload +} + var embed_url = new URL(location); embed_url.searchParams.delete('v'); var short_url = location.origin + '/' + video_data.id + embed_url.search; diff --git a/config/config.example.yml b/config/config.example.yml index b44fcc0e..b1a76edf 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -718,6 +718,20 @@ default_user_preferences: # Video player behavior # ----------------------------- + ## + ## Automatically preload video on page load. This option controls the + ## value for the "preload" attribute of the HTML5
    +
    + + checked<% end %>> +
    +
    checked<% end %>> -- cgit v1.2.3 From 824cc1a5aa6321936357677e77b6638c96137225 Mon Sep 17 00:00:00 2001 From: Thomas Lange Date: Wed, 27 Sep 2023 15:25:29 +0200 Subject: Don't redefine the "preload" option in player.js If the HTML5 "
  • - #{go_to_youtube} + #{go_to_youtube}
  • END_HTML diff --git a/src/invidious/views/components/video-context-buttons.ecr b/src/invidious/views/components/video-context-buttons.ecr index 385ed6b3..22458a03 100644 --- a/src/invidious/views/components/video-context-buttons.ecr +++ b/src/invidious/views/components/video-context-buttons.ecr @@ -1,6 +1,6 @@
    - " href="https://www.youtube.com/watch<%=endpoint_params%>"> + " rel="noreferrer noopener" href="https://www.youtube.com/watch<%=endpoint_params%>"> " href="/watch<%=endpoint_params%>&listen=1"> diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 24ba437d..c27ddba6 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -83,7 +83,7 @@ <% if !playlist.is_a? InvidiousPlaylist %>
    - + <%= translate(locale, "View playlist on YouTube") %> | diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 7a1cf2c3..586b4cff 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -26,7 +26,7 @@ - + <%= rendered "components/player_sources" %> <%= title %> - Invidious @@ -123,8 +123,8 @@ we're going to need to do it here in order to allow for translations. link_yt_embed = IV::HttpServer::Utils.add_params_to_url(link_yt_embed, link_yt_param) end -%> - <%= translate(locale, "videoinfo_watch_on_youTube") %> - (<%= translate(locale, "videoinfo_youTube_embed_link") %>) + <%= translate(locale, "videoinfo_watch_on_youTube") %> + (<%= translate(locale, "videoinfo_youTube_embed_link") %>)

    -- cgit v1.2.3 From 9d66676f2dbb18a87ca7515e839f1c64688ecd39 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Wed, 1 May 2024 22:17:41 -0400 Subject: Use full URL in the og:image property. --- src/invidious/views/channel.ecr | 4 ++-- src/invidious/views/watch.ecr | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 09df106d..a84e44bc 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -30,13 +30,13 @@ - + - + <%- end -%> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 7a1cf2c3..9e7467dd 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -10,7 +10,7 @@ - + -- cgit v1.2.3 From c4fec89a9bac0228f6fac6ab2e8547132b57cc98 Mon Sep 17 00:00:00 2001 From: ulmemxpoc <123284914+ulmemxpoc@users.noreply.github.com> Date: Fri, 10 May 2024 11:23:11 -0700 Subject: Apply suggestions from code review --- src/invidious/frontend/comments_youtube.cr | 2 +- src/invidious/views/watch.ecr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/frontend/comments_youtube.cr b/src/invidious/frontend/comments_youtube.cr index f9eb44ef..a0e1d783 100644 --- a/src/invidious/frontend/comments_youtube.cr +++ b/src/invidious/frontend/comments_youtube.cr @@ -149,7 +149,7 @@ module Invidious::Frontend::Comments if comments["videoId"]? html << <<-END_HTML - [YT] + [YT] | END_HTML elsif comments["authorId"]? diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 586b4cff..fd9e1592 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -26,7 +26,7 @@ - + <%= rendered "components/player_sources" %> <%= title %> - Invidious -- cgit v1.2.3 From 90fcf80a8d20b07e18070800474e0fc8ee342020 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Mon, 13 May 2024 19:27:27 -0400 Subject: Handle playlists cataloged as Podcast Videos of a playlist cataloged as podcast are called episodes therefore Invidious was not able to find `video` in the `text` value inside the stats array. --- src/invidious/playlists.cr | 2 ++ 1 file changed, 2 insertions(+) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 955e0855..a227f794 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -366,6 +366,8 @@ def fetch_playlist(plid : String) if text.includes? "video" video_count = text.gsub(/\D/, "").to_i? || 0 + elsif text.includes? "episode" + video_count = text.gsub(/\D/, "").to_i? || 0 elsif text.includes? "view" views = text.gsub(/\D/, "").to_i64? || 0_i64 else -- cgit v1.2.3 From e0d0dbde3cd1cba313d990244977a890a32976de Mon Sep 17 00:00:00 2001 From: Fijxu Date: Mon, 13 May 2024 21:07:46 -0400 Subject: API: Check if playlist has any videos on it. Invidious assumes that every playlist will have at least one video because it needs to check for the `index` key. So if there is no videos on a playlist, there is no `index` key and Invidious throws `Index out of bounds` --- src/invidious/routes/api/v1/misc.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 12942906..0c79692d 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -74,7 +74,9 @@ module Invidious::Routes::API::V1::Misc response = playlist.to_json(offset, video_id: video_id) json_response = JSON.parse(response) - if json_response["videos"].as_a[0]["index"] != offset + if json_response["videos"].as_a.empty? + json_response = JSON.parse(response) + elsif json_response["videos"].as_a[0]["index"] != offset offset = json_response["videos"].as_a[0]["index"].as_i lookback = offset < 50 ? offset : 50 response = playlist.to_json(offset - lookback) -- cgit v1.2.3 From 71a821a7e65de56ba4816bb07380cebf9914c00a Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Sat, 20 Apr 2024 18:50:17 +0200 Subject: Return actual height, width and fps for streams in /api/v1/videos --- src/invidious/jsonify/api_v1/video_json.cr | 67 +++++++++++++++++------------- 1 file changed, 38 insertions(+), 29 deletions(-) (limited to 'src') diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 0dced80b..8c1f5c3c 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -114,25 +114,30 @@ module Invidious::JSONify::APIv1 json.field "projectionType", fmt["projectionType"] - if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) - fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 + height = fmt["height"]?.try &.as_i + width = fmt["width"]?.try &.as_i + + fps = fmt["fps"]?.try &.as_i + + if fps json.field "fps", fps - json.field "container", fmt_info["ext"] - json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] + end - if fmt_info["height"]? - json.field "resolution", "#{fmt_info["height"]}p" + if height && width + json.field "size", "#{width}x#{height}" - quality_label = "#{fmt_info["height"]}p" - if fps > 30 - quality_label += "60" - end - json.field "qualityLabel", quality_label + quality_label = "#{width > height ? height : width}" - if fmt_info["width"]? - json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" - end + if fps && fps > 30 + quality_label += fps.to_s end + + json.field "qualityLabel", quality_label + end + + if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) + json.field "container", fmt_info["ext"] + json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] end # Livestream chunk infos @@ -163,26 +168,30 @@ module Invidious::JSONify::APIv1 json.field "bitrate", fmt["bitrate"].as_i.to_s if fmt["bitrate"]? - fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) - if fmt_info - fps = fmt_info["fps"]?.try &.to_i || fmt["fps"]?.try &.as_i || 30 + height = fmt["height"]?.try &.as_i + width = fmt["width"]?.try &.as_i + + fps = fmt["fps"]?.try &.as_i + + if fps json.field "fps", fps - json.field "container", fmt_info["ext"] - json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] + end - if fmt_info["height"]? - json.field "resolution", "#{fmt_info["height"]}p" + if height && width + json.field "size", "#{width}x#{height}" - quality_label = "#{fmt_info["height"]}p" - if fps > 30 - quality_label += "60" - end - json.field "qualityLabel", quality_label + quality_label = "#{width > height ? height : width}" - if fmt_info["width"]? - json.field "size", "#{fmt_info["width"]}x#{fmt_info["height"]}" - end + if fps && fps > 30 + quality_label += fps.to_s end + + json.field "qualityLabel", quality_label + end + + if fmt_info = Invidious::Videos::Formats.itag_to_metadata?(fmt["itag"]) + json.field "container", fmt_info["ext"] + json.field "encoding", fmt_info["vcodec"]? || fmt_info["acodec"] end end end -- cgit v1.2.3 From f57aac5815e20bed2b495cb1994f4d8d50654b61 Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Sun, 21 Apr 2024 14:58:12 +0200 Subject: Fix the missing `p` in the quality labels. Co-authored-by: Samantaz Fox --- src/invidious/jsonify/api_v1/video_json.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 8c1f5c3c..7f17f35a 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -126,7 +126,7 @@ module Invidious::JSONify::APIv1 if height && width json.field "size", "#{width}x#{height}" - quality_label = "#{width > height ? height : width}" + quality_label = "#{width > height ? height : width}p" if fps && fps > 30 quality_label += fps.to_s @@ -180,7 +180,7 @@ module Invidious::JSONify::APIv1 if height && width json.field "size", "#{width}x#{height}" - quality_label = "#{width > height ? height : width}" + quality_label = "#{width > height ? height : width}p" if fps && fps > 30 quality_label += fps.to_s -- cgit v1.2.3 From 57e606cb43d43c627708f0538eddcde3b0f580a0 Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Thu, 25 Apr 2024 21:38:51 +0200 Subject: Add back missing resolution field --- src/invidious/jsonify/api_v1/video_json.cr | 2 ++ 1 file changed, 2 insertions(+) (limited to 'src') diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 7f17f35a..6e8c3a72 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -125,6 +125,7 @@ module Invidious::JSONify::APIv1 if height && width json.field "size", "#{width}x#{height}" + json.field "resolution" "#{height}p" quality_label = "#{width > height ? height : width}p" @@ -179,6 +180,7 @@ module Invidious::JSONify::APIv1 if height && width json.field "size", "#{width}x#{height}" + json.field "resolution" "#{height}p" quality_label = "#{width > height ? height : width}p" -- cgit v1.2.3 From 3b773c4f77c1469bcd158f7ab912fcb57af7b014 Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Thu, 25 Apr 2024 21:51:19 +0200 Subject: Fix missing commas --- src/invidious/jsonify/api_v1/video_json.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 6e8c3a72..59714828 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -125,7 +125,7 @@ module Invidious::JSONify::APIv1 if height && width json.field "size", "#{width}x#{height}" - json.field "resolution" "#{height}p" + json.field "resolution", "#{height}p" quality_label = "#{width > height ? height : width}p" @@ -180,7 +180,7 @@ module Invidious::JSONify::APIv1 if height && width json.field "size", "#{width}x#{height}" - json.field "resolution" "#{height}p" + json.field "resolution", "#{height}p" quality_label = "#{width > height ? height : width}p" -- cgit v1.2.3 From 9cd2e93a2ee8f2f0f570bcb8fbe584f5c502a34e Mon Sep 17 00:00:00 2001 From: thansk <53181514+thansk@users.noreply.github.com> Date: Sun, 19 May 2024 11:46:55 +0000 Subject: feat: allow submitting search with mouse --- assets/css/default.css | 19 ++++++++++++++++++- src/invidious/views/components/search_box.ecr | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index a47762ec..d86ec7bc 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -278,7 +278,14 @@ div.thumbnail > .bottom-right-overlay { display: inline; } -.searchbar .pure-form fieldset { padding: 0; } +.searchbar .pure-form { + display: flex; +} + +.searchbar .pure-form fieldset { + padding: 0; + flex: 1; +} .searchbar input[type="search"] { width: 100%; @@ -310,6 +317,16 @@ input[type="search"]::-webkit-search-cancel-button { background-size: 14px; } +.searchbar #searchbutton { + border: 0; + background: none; + text-transform: uppercase; +} + +.searchbar #searchbutton:hover { + color: rgb(0, 182, 240); +} + .user-field { display: flex; flex-direction: row; diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr index a03785d1..c5488255 100644 --- a/src/invidious/views/components/search_box.ecr +++ b/src/invidious/views/components/search_box.ecr @@ -6,4 +6,5 @@ title="<%= translate(locale, "search") %>" value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> + -- cgit v1.2.3 From 5abafb8296330dfc7fe7ab630661e0cc8e04ef85 Mon Sep 17 00:00:00 2001 From: thansk <53181514+thansk@users.noreply.github.com> Date: Mon, 20 May 2024 11:49:56 +0000 Subject: fix: use a search icon instead of text --- assets/css/default.css | 3 +++ src/invidious/views/components/search_box.ecr | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index d86ec7bc..20ec3222 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -321,6 +321,9 @@ input[type="search"]::-webkit-search-cancel-button { border: 0; background: none; text-transform: uppercase; + display: grid; + place-items: center; + width: 1.5em; } .searchbar #searchbutton:hover { diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr index c5488255..b679b031 100644 --- a/src/invidious/views/components/search_box.ecr +++ b/src/invidious/views/components/search_box.ecr @@ -6,5 +6,9 @@ title="<%= translate(locale, "search") %>" value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> - + -- cgit v1.2.3 From 1ce2d10c505a7e0c3972acfb626a0ae3c9af3d57 Mon Sep 17 00:00:00 2001 From: thansk <53181514+thansk@users.noreply.github.com> Date: Mon, 20 May 2024 14:17:30 +0000 Subject: fix: use ion icon for search icon --- assets/css/default.css | 7 ++----- src/invidious/views/components/search_box.ecr | 4 +--- 2 files changed, 3 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/assets/css/default.css b/assets/css/default.css index 20ec3222..1445f65f 100644 --- a/assets/css/default.css +++ b/assets/css/default.css @@ -318,12 +318,9 @@ input[type="search"]::-webkit-search-cancel-button { } .searchbar #searchbutton { - border: 0; + border: none; background: none; - text-transform: uppercase; - display: grid; - place-items: center; - width: 1.5em; + margin-top: 0; } .searchbar #searchbutton:hover { diff --git a/src/invidious/views/components/search_box.ecr b/src/invidious/views/components/search_box.ecr index b679b031..29da2c52 100644 --- a/src/invidious/views/components/search_box.ecr +++ b/src/invidious/views/components/search_box.ecr @@ -7,8 +7,6 @@ value="<%= env.get?("search").try {|x| HTML.escape(x.as(String)) } %>"> -- cgit v1.2.3 From 6b7e7301009e1a9fc2b536bd8d8de04fb8e22ec0 Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 22 May 2024 13:10:46 -0700 Subject: Validate override for crystal 1.12.1 --- src/invidious/helpers/crystal_class_overrides.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/crystal_class_overrides.cr b/src/invidious/helpers/crystal_class_overrides.cr index 71038703..a7d2a5e6 100644 --- a/src/invidious/helpers/crystal_class_overrides.cr +++ b/src/invidious/helpers/crystal_class_overrides.cr @@ -20,7 +20,7 @@ class HTTP::Client # Override stdlib to automatically initialize proxy if configured # - # Accurate as of crystal 1.10.1 + # Accurate as of crystal 1.12.1 def initialize(@host : String, port = nil, tls : TLSContext = nil) check_host_only(@host) -- cgit v1.2.3 From cff25a7b2569b15d6129edffc6ca01e7c3a69d76 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 24 Sep 2023 15:09:59 -0400 Subject: Refactor instance fetching logic into separate job --- src/invidious.cr | 2 + src/invidious/helpers/utils.cr | 62 -------------------- src/invidious/jobs/instance_refresh_job.cr | 94 ++++++++++++++++++++++++++++++ src/invidious/routes/misc.cr | 11 +++- 4 files changed, 106 insertions(+), 63 deletions(-) create mode 100644 src/invidious/jobs/instance_refresh_job.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index e0bd0101..64578061 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -185,6 +185,8 @@ Invidious::Jobs.register Invidious::Jobs::NotificationJob.new(CONNECTION_CHANNEL Invidious::Jobs.register Invidious::Jobs::ClearExpiredItemsJob.new +Invidious::Jobs.register Invidious::Jobs::InstanceListRefreshJob.new + Invidious::Jobs.start_all def popular_videos diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index e438e3b9..219d54f8 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -323,68 +323,6 @@ def parse_range(range) return 0_i64, nil end -def fetch_random_instance - begin - instance_api_client = make_client(URI.parse("https://api.invidious.io")) - - # Timeouts - instance_api_client.connect_timeout = 10.seconds - instance_api_client.dns_timeout = 10.seconds - - instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a - instance_api_client.close - rescue Socket::ConnectError | IO::TimeoutError | JSON::ParseException - instance_list = [] of JSON::Any - end - - filtered_instance_list = [] of String - - instance_list.each do |data| - # TODO Check if current URL is onion instance and use .onion types if so. - if data[1]["type"] == "https" - # Instances can have statistics disabled, which is an requirement of version validation. - # as_nil? doesn't exist. Thus we'll have to handle the error raised if as_nil fails. - begin - data[1]["stats"].as_nil - next - rescue TypeCastError - end - - # stats endpoint could also lack the software dict. - next if data[1]["stats"]["software"]?.nil? - - # Makes sure the instance isn't too outdated. - if remote_version = data[1]["stats"]?.try &.["software"]?.try &.["version"] - remote_commit_date = remote_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/) - next if !remote_commit_date - - remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC) - local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC) - - next if (remote_commit_date - local_commit_date).abs.days > 30 - - begin - data[1]["monitor"].as_nil - health = data[1]["monitor"].as_h["dailyRatios"][0].as_h["ratio"] - filtered_instance_list << data[0].as_s if health.to_s.to_f > 90 - rescue TypeCastError - # We can't check the health if the monitoring is broken. Thus we'll just add it to the list - # and move on. Ideally we'll ignore any instance that has broken health monitoring but due to the fact that - # it's an error that often occurs with all the instances at the same time, we have to just skip the check. - filtered_instance_list << data[0].as_s - end - end - end - end - - # If for some reason no instances managed to get fetched successfully then we'll just redirect to redirect.invidious.io - if filtered_instance_list.size == 0 - return "redirect.invidious.io" - end - - return filtered_instance_list.sample(1)[0] -end - def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = "…") : String str = uri.to_s.sub(/^https?:\/\//, "") if str.size > max_length diff --git a/src/invidious/jobs/instance_refresh_job.cr b/src/invidious/jobs/instance_refresh_job.cr new file mode 100644 index 00000000..bfda9f3f --- /dev/null +++ b/src/invidious/jobs/instance_refresh_job.cr @@ -0,0 +1,94 @@ +class Invidious::Jobs::InstanceListRefreshJob < Invidious::Jobs::BaseJob + # We update the internals of a constant as so it can be accessed from anywhere + # within the codebase + # + # "INSTANCES" => Array(Tuple(String, String)) # region, instance + + INSTANCES = {"INSTANCES" => [] of Tuple(String, String)} + + def initialize + end + + def begin + loop do + refresh_instances + LOGGER.info("InstanceListRefreshJob: Done, sleeping for 30 minutes") + sleep 30.minute + Fiber.yield + end + end + + # Refreshes the list of instances used for redirects. + # + # Does the following three checks for each instance + # - Is it a clear-net instance? + # - Is it an instance with a good uptime? + # - Is it an updated instance? + private def refresh_instances + raw_instance_list = self.fetch_instances + filtered_instance_list = [] of Tuple(String, String) + + raw_instance_list.each do |instance_data| + # TODO allow Tor hidden service instances when the current instance + # is also a hidden service. Same for i2p and any other non-clearnet instances. + begin + domain = instance_data[0] + info = instance_data[1] + stats = info["stats"] + + next unless info["type"] == "https" + next if bad_uptime?(info["monitor"]) + next if outdated?(stats["software"]["version"]) + + filtered_instance_list << {info["region"].as_s, domain.as_s} + rescue ex + if domain + LOGGER.info("InstanceListRefreshJob: failed to parse information from '#{domain}' because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ") + else + LOGGER.info("InstanceListRefreshJob: failed to parse information from an instance because \"#{ex}\"\n\"#{ex.backtrace.join('\n')}\" ") + end + end + end + + if !filtered_instance_list.empty? + INSTANCES["INSTANCES"] = filtered_instance_list + end + end + + # Fetches information regarding instances from api.invidious.io or an otherwise configured URL + private def fetch_instances : Array(JSON::Any) + begin + instance_api_client = make_client(URI.parse("https://api.invidious.io")) + + # Timeouts + instance_api_client.connect_timeout = 10.seconds + instance_api_client.dns_timeout = 10.seconds + + raw_instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a + instance_api_client.close + rescue Socket::ConnectError | IO::TimeoutError | JSON::ParseException + raw_instance_list = [] of JSON::Any + end + + return raw_instance_list + end + + # Checks if the given target instance is outdated + private def outdated?(target_instance_version) : Bool + remote_commit_date = target_instance_version.as_s.match(/\d{4}\.\d{2}\.\d{2}/) + return false if !remote_commit_date + + remote_commit_date = Time.parse(remote_commit_date[0], "%Y.%m.%d", Time::Location::UTC) + local_commit_date = Time.parse(CURRENT_VERSION, "%Y.%m.%d", Time::Location::UTC) + + return (remote_commit_date - local_commit_date).abs.days > 30 + end + + # Checks if the uptime of the target instance is greater than 90% over a 30 day period + private def bad_uptime?(target_instance_health_monitor) : Bool + return false if !target_instance_health_monitor["statusClass"] == "success" + return false if target_instance_health_monitor["30dRatio"]["ratio"].as_s.to_f < 90 + + return true + end +end diff --git a/src/invidious/routes/misc.cr b/src/invidious/routes/misc.cr index d6bd9571..8b620d63 100644 --- a/src/invidious/routes/misc.cr +++ b/src/invidious/routes/misc.cr @@ -40,7 +40,16 @@ module Invidious::Routes::Misc def self.cross_instance_redirect(env) referer = get_referer(env) - instance_url = fetch_random_instance + + instance_list = Invidious::Jobs::InstanceListRefreshJob::INSTANCES["INSTANCES"] + if instance_list.empty? + instance_url = "redirect.invidious.io" + else + # Sample returns an array + # Instances are packaged as {region, domain} in the instance list + instance_url = instance_list.sample(1)[0][1] + end + env.redirect "https://#{instance_url}#{referer}" end end -- cgit v1.2.3 From 41c978d350eaf7a78951d58ae859830a300f6191 Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 7 Dec 2023 11:21:06 -0800 Subject: Use HTTP::Client directly in instance list job The HTTP::Client created via `make_client` is affected by the force_resolve configuration option. However, api.invidious.io does not support ipv6 and as such any request with ipv6 to api.invidious.io will instead raise. Directly calling the HTTP::Client will ignore the force_resolve option allowing requests to go through ipv4 when needed. --- src/invidious/jobs/instance_refresh_job.cr | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/jobs/instance_refresh_job.cr b/src/invidious/jobs/instance_refresh_job.cr index bfda9f3f..38071998 100644 --- a/src/invidious/jobs/instance_refresh_job.cr +++ b/src/invidious/jobs/instance_refresh_job.cr @@ -58,7 +58,10 @@ class Invidious::Jobs::InstanceListRefreshJob < Invidious::Jobs::BaseJob # Fetches information regarding instances from api.invidious.io or an otherwise configured URL private def fetch_instances : Array(JSON::Any) begin - instance_api_client = make_client(URI.parse("https://api.invidious.io")) + # We directly call the stdlib HTTP::Client here as it allows us to negate the effects + # of the force_resolve config option. This is needed as api.invidious.io does not support ipv6 + # and as such the following request raises if we were to use force_resolve with the ipv6 value. + instance_api_client = HTTP::Client.new(URI.parse("https://api.invidious.io")) # Timeouts instance_api_client.connect_timeout = 10.seconds -- cgit v1.2.3 From aa96cf34530e803ef8b6bb3e29840aed5d805c51 Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 7 Dec 2023 11:43:44 -0800 Subject: Fix invalid logic for instance uptime comparison --- src/invidious/jobs/instance_refresh_job.cr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/jobs/instance_refresh_job.cr b/src/invidious/jobs/instance_refresh_job.cr index 38071998..b385d45c 100644 --- a/src/invidious/jobs/instance_refresh_job.cr +++ b/src/invidious/jobs/instance_refresh_job.cr @@ -69,7 +69,7 @@ class Invidious::Jobs::InstanceListRefreshJob < Invidious::Jobs::BaseJob raw_instance_list = JSON.parse(instance_api_client.get("/instances.json").body).as_a instance_api_client.close - rescue Socket::ConnectError | IO::TimeoutError | JSON::ParseException + rescue ex : Socket::ConnectError | IO::TimeoutError | JSON::ParseException raw_instance_list = [] of JSON::Any end @@ -89,9 +89,9 @@ class Invidious::Jobs::InstanceListRefreshJob < Invidious::Jobs::BaseJob # Checks if the uptime of the target instance is greater than 90% over a 30 day period private def bad_uptime?(target_instance_health_monitor) : Bool - return false if !target_instance_health_monitor["statusClass"] == "success" - return false if target_instance_health_monitor["30dRatio"]["ratio"].as_s.to_f < 90 + return true if !target_instance_health_monitor["statusClass"] == "success" + return true if target_instance_health_monitor["30dRatio"]["ratio"].as_s.to_f < 90 - return true + return false end end -- cgit v1.2.3 From 9980c0e00f99963373beec50736c97f240f31dcb Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 22 May 2024 13:28:15 -0700 Subject: Update uptime logic to handle updown.io response --- src/invidious/jobs/instance_refresh_job.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/jobs/instance_refresh_job.cr b/src/invidious/jobs/instance_refresh_job.cr index b385d45c..cb4280b9 100644 --- a/src/invidious/jobs/instance_refresh_job.cr +++ b/src/invidious/jobs/instance_refresh_job.cr @@ -89,8 +89,8 @@ class Invidious::Jobs::InstanceListRefreshJob < Invidious::Jobs::BaseJob # Checks if the uptime of the target instance is greater than 90% over a 30 day period private def bad_uptime?(target_instance_health_monitor) : Bool - return true if !target_instance_health_monitor["statusClass"] == "success" - return true if target_instance_health_monitor["30dRatio"]["ratio"].as_s.to_f < 90 + return true if !target_instance_health_monitor["down"].as_bool == false + return true if target_instance_health_monitor["uptime"].as_f < 90 return false end -- cgit v1.2.3 From 31ad708206dc108714e36f617c6bce5f85c80b8b Mon Sep 17 00:00:00 2001 From: meatball Date: Thu, 30 May 2024 21:56:33 +0200 Subject: fix: Handle nil value for genreUcid in Video struct --- src/invidious/videos.cr | 2 +- src/invidious/videos/parser.cr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index c218b4ef..cdfca02c 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -250,7 +250,7 @@ struct Video end def genre_url : String? - info["genreUcid"]? ? "/channel/#{info["genreUcid"]}" : nil + info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil end def is_vr : Bool? diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 0e1a947c..bc3c844d 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -327,7 +327,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any if metadata_title == "Category" contents = contents.try &.dig?("runs", 0) - + genre = contents.try &.["text"]? genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId") elsif metadata_title == "License" @@ -424,7 +424,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), # Video metadata "genre" => JSON::Any.new(genre.try &.as_s || ""), - "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), + "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || nil), "license" => JSON::Any.new(license.try &.as_s || ""), # Music section "music" => JSON.parse(music_list.to_json), -- cgit v1.2.3 From 629599f9403a4b5b5ceda58f2d17ad81745f6981 Mon Sep 17 00:00:00 2001 From: meatball Date: Thu, 30 May 2024 21:57:15 +0200 Subject: Fix change in parser file --- src/invidious/videos/parser.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index bc3c844d..85f17525 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -327,7 +327,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any if metadata_title == "Category" contents = contents.try &.dig?("runs", 0) - + genre = contents.try &.["text"]? genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId") elsif metadata_title == "License" -- cgit v1.2.3 From 59575236243cb28f3e0199e028a9042970f133ba Mon Sep 17 00:00:00 2001 From: meatball Date: Thu, 30 May 2024 22:13:30 +0200 Subject: Improve code quallity --- src/invidious/videos/parser.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 85f17525..4bdb2512 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -424,7 +424,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), # Video metadata "genre" => JSON::Any.new(genre.try &.as_s || ""), - "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || nil), + "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?), "license" => JSON::Any.new(license.try &.as_s || ""), # Music section "music" => JSON.parse(music_list.to_json), -- cgit v1.2.3 From 04ca64691b76b432374e4bb3dcde64cc37a97869 Mon Sep 17 00:00:00 2001 From: meatball Date: Thu, 30 May 2024 22:37:55 +0200 Subject: Make solution complaint with spec --- src/invidious/videos.cr | 2 +- src/invidious/videos/parser.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index cdfca02c..5a4a55c3 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -250,7 +250,7 @@ struct Video end def genre_url : String? - info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil + info["genreUcid"]? == "" ? nil : "/channel/#{info["genreUcid"]}" end def is_vr : Bool? diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 4bdb2512..0e1a947c 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -424,7 +424,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), # Video metadata "genre" => JSON::Any.new(genre.try &.as_s || ""), - "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?), + "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), "license" => JSON::Any.new(license.try &.as_s || ""), # Music section "music" => JSON.parse(music_list.to_json), -- cgit v1.2.3 From 0224162ad22dc19d58a73202d796eb3e99f0a71c Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 11 Jun 2024 17:57:33 -0700 Subject: Rewrite transcript logic to be more generic The transcript logic in Invidious was written specifically as a workaround for captions, and not transcripts as a feature. This commit genericises the logic a bit as so it can be used for implementing transcripts within Invidious' API and UI as well. The most notable change is the added parsing of section headings when it was previously skipped over in favor of regular lines. --- src/invidious/routes/api/v1/videos.cr | 9 +++- src/invidious/videos/transcript.cr | 86 ++++++++++++++++++++++------------- 2 files changed, 61 insertions(+), 34 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 9281f4dd..faff2f59 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -89,9 +89,14 @@ module Invidious::Routes::API::V1::Videos if CONFIG.use_innertube_for_captions params = Invidious::Videos::Transcript.generate_param(id, caption.language_code, caption.auto_generated) - initial_data = YoutubeAPI.get_transcript(params) - webvtt = Invidious::Videos::Transcript.convert_transcripts_to_vtt(initial_data, caption.language_code) + transcript = Invidious::Videos::Transcript.from_raw( + YoutubeAPI.get_transcript(params), + caption.language_code, + caption.auto_generated + ) + + webvtt = transcript.to_vtt else # Timedtext API handling url = URI.parse("#{caption.base_url}&tlang=#{tlang}").request_target diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index dac00eea..42f29f46 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -1,8 +1,21 @@ module Invidious::Videos - # Namespace for methods primarily relating to Transcripts - module Transcript - record TranscriptLine, start_ms : Time::Span, end_ms : Time::Span, line : String + # A `Transcripts` struct encapsulates a sequence of lines that together forms the whole transcript for a given YouTube video. + # These lines can be categorized into two types: section headings and regular lines representing content from the video. + struct Transcript + # Types + record HeadingLine, start_ms : Time::Span, end_ms : Time::Span, line : String + record RegularLine, start_ms : Time::Span, end_ms : Time::Span, line : String + alias TranscriptLine = HeadingLine | RegularLine + + property lines : Array(TranscriptLine) + property language_code : String + property auto_generated : Bool + + # Initializes a new Transcript struct with the contents and associated metadata describing it + def initialize(@lines : Array(TranscriptLine), @language_code : String, @auto_generated : Bool) + end + # Generates a protobuf string to fetch the requested transcript from YouTube def self.generate_param(video_id : String, language_code : String, auto_generated : Bool) : String kind = auto_generated ? "asr" : "" @@ -30,48 +43,57 @@ module Invidious::Videos return params end - def self.convert_transcripts_to_vtt(initial_data : Hash(String, JSON::Any), target_language : String) : String - # Convert into array of TranscriptLine - lines = self.parse(initial_data) - - settings_field = { - "Kind" => "captions", - "Language" => target_language, - } - - # Taken from Invidious::Videos::Captions::Metadata.timedtext_to_vtt() - vtt = WebVTT.build(settings_field) do |vtt| - lines.each do |line| - vtt.cue(line.start_ms, line.end_ms, line.line) - end - end - - return vtt - end - - private def self.parse(initial_data : Hash(String, JSON::Any)) + # Constructs a Transcripts struct from the initial YouTube response + def self.from_raw(initial_data : Hash(String, JSON::Any), language_code : String, auto_generated : Bool) body = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", "content", "transcriptSearchPanelRenderer", "body", "transcriptSegmentListRenderer", "initialSegments").as_a lines = [] of TranscriptLine + body.each do |line| - # Transcript section headers. They are not apart of the captions and as such we can safely skip them. - if line.as_h.has_key?("transcriptSectionHeaderRenderer") - next + if unpacked_line = line["transcriptSectionHeaderRenderer"]? + line_type = HeadingLine + else + unpacked_line = line["transcriptSegmentRenderer"] + line_type = RegularLine end - line = line["transcriptSegmentRenderer"] + start_ms = unpacked_line["startMs"].as_s.to_i.millisecond + end_ms = unpacked_line["endMs"].as_s.to_i.millisecond + text = extract_text(unpacked_line["snippet"]) || "" - start_ms = line["startMs"].as_s.to_i.millisecond - end_ms = line["endMs"].as_s.to_i.millisecond + lines << line_type.new(start_ms, end_ms, text) + end - text = extract_text(line["snippet"]) || "" + return Transcript.new( + lines: lines, + language_code: language_code, + auto_generated: auto_generated, + ) + end - lines << TranscriptLine.new(start_ms, end_ms, text) + # Converts transcript lines to a WebVTT file + # + # This is used within Invidious to replace subtitles + # as to workaround YouTube's rate-limited timedtext endpoint. + def to_vtt + settings_field = { + "Kind" => "captions", + "Language" => @language_code, + } + + vtt = WebVTT.build(settings_field) do |vtt| + @lines.each do |line| + # Section headers are excluded from the VTT conversion as to + # match the regular captions returned from YouTube as much as possible + next if line.is_a? HeadingLine + + vtt.cue(line.start_ms, line.end_ms, line.line) + end end - return lines + return vtt end end end -- cgit v1.2.3 From 5b519123a76879edca3d5fa5cff717b58482e7e5 Mon Sep 17 00:00:00 2001 From: syeopite Date: Tue, 11 Jun 2024 18:46:34 -0700 Subject: Raise error when transcript does not exist --- src/invidious/videos/transcript.cr | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index 42f29f46..76fb8610 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -45,13 +45,19 @@ module Invidious::Videos # Constructs a Transcripts struct from the initial YouTube response def self.from_raw(initial_data : Hash(String, JSON::Any), language_code : String, auto_generated : Bool) - body = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", - "content", "transcriptSearchPanelRenderer", "body", "transcriptSegmentListRenderer", - "initialSegments").as_a + segment_list = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", + "content", "transcriptSearchPanelRenderer", "body", "transcriptSegmentListRenderer" + ) + + if !segment_list["initialSegments"]? + raise NotFoundException.new("Requested transcript does not exist") + end + + initial_segments = segment_list["initialSegments"].as_a lines = [] of TranscriptLine - body.each do |line| + initial_segments.each do |line| if unpacked_line = line["transcriptSectionHeaderRenderer"]? line_type = HeadingLine else -- cgit v1.2.3 From f466116cd715120a8acea2c388e306caaf62abb0 Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 13 Jun 2024 09:05:47 -0700 Subject: Extract label for transcript in YouTube response --- src/invidious/videos/transcript.cr | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index 76fb8610..9cd064c5 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -8,11 +8,16 @@ module Invidious::Videos alias TranscriptLine = HeadingLine | RegularLine property lines : Array(TranscriptLine) + property language_code : String property auto_generated : Bool + # User friendly label for the current transcript. + # Example: "English (auto-generated)" + property label : String + # Initializes a new Transcript struct with the contents and associated metadata describing it - def initialize(@lines : Array(TranscriptLine), @language_code : String, @auto_generated : Bool) + def initialize(@lines : Array(TranscriptLine), @language_code : String, @auto_generated : Bool, @label : String) end # Generates a protobuf string to fetch the requested transcript from YouTube @@ -45,14 +50,29 @@ module Invidious::Videos # Constructs a Transcripts struct from the initial YouTube response def self.from_raw(initial_data : Hash(String, JSON::Any), language_code : String, auto_generated : Bool) - segment_list = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", - "content", "transcriptSearchPanelRenderer", "body", "transcriptSegmentListRenderer" - ) + transcript_panel = initial_data.dig("actions", 0, "updateEngagementPanelAction", "content", "transcriptRenderer", + "content", "transcriptSearchPanelRenderer") + + segment_list = transcript_panel.dig("body", "transcriptSegmentListRenderer") if !segment_list["initialSegments"]? raise NotFoundException.new("Requested transcript does not exist") end + # Extract user-friendly label for the current transcript + + footer_language_menu = transcript_panel.dig?( + "footer", "transcriptFooterRenderer", "languageMenu", "sortFilterSubMenuRenderer", "subMenuItems" + ) + + if footer_language_menu + label = footer_language_menu.as_a.select(&.["selected"].as_bool)[0]["title"].as_s + else + label = language_code + end + + # Extract transcript lines + initial_segments = segment_list["initialSegments"].as_a lines = [] of TranscriptLine @@ -76,6 +96,7 @@ module Invidious::Videos lines: lines, language_code: language_code, auto_generated: auto_generated, + label: label ) end -- cgit v1.2.3 From c24ed85110cfa006992ce16bd4432eb39c8db71b Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 16 Jun 2024 14:49:48 -0700 Subject: Fix named arg syntax when passing force_resolve --- src/invidious/routes/video_playback.cr | 8 ++++---- src/invidious/yt_backend/connection_pool.cr | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index ec18f3b8..254c0b46 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -42,7 +42,7 @@ module Invidious::Routes::VideoPlayback headers["Range"] = "bytes=#{range_for_head}" end - client = make_client(URI.parse(host), region, force_resolve = true) + client = make_client(URI.parse(host), region, force_resolve: true) response = HTTP::Client::Response.new(500) error = "" 5.times do @@ -57,7 +57,7 @@ module Invidious::Routes::VideoPlayback if new_host != host host = new_host client.close - client = make_client(URI.parse(new_host), region, force_resolve = true) + client = make_client(URI.parse(new_host), region, force_resolve: true) end url = "#{location.request_target}&host=#{location.host}#{region ? "®ion=#{region}" : ""}" @@ -71,7 +71,7 @@ module Invidious::Routes::VideoPlayback fvip = "3" host = "https://r#{fvip}---#{mn}.googlevideo.com" - client = make_client(URI.parse(host), region, force_resolve = true) + client = make_client(URI.parse(host), region, force_resolve: true) rescue ex error = ex.message end @@ -196,7 +196,7 @@ module Invidious::Routes::VideoPlayback break else client.close - client = make_client(URI.parse(host), region, force_resolve = true) + client = make_client(URI.parse(host), region, force_resolve: true) end end diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index d3dbcc0e..bcf6a003 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -70,7 +70,7 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false) end def make_client(url : URI, region = nil, force_resolve : Bool = false, &block) - client = make_client(url, region, force_resolve) + client = make_client(url, region, force_resolve: force_resolve) begin yield client ensure -- cgit v1.2.3 From 248df785d764023d8ffcdfa8cad08c17a12fe7a6 Mon Sep 17 00:00:00 2001 From: meatball Date: Tue, 18 Jun 2024 20:55:14 +0200 Subject: Update spec and rollback to last commits changes --- spec/invidious/videos/regular_videos_extract_spec.cr | 4 ++-- spec/invidious/videos/scheduled_live_extract_spec.cr | 2 +- src/invidious/videos.cr | 2 +- src/invidious/videos/parser.cr | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/spec/invidious/videos/regular_videos_extract_spec.cr b/spec/invidious/videos/regular_videos_extract_spec.cr index a6a3e60a..b35738f4 100644 --- a/spec/invidious/videos/regular_videos_extract_spec.cr +++ b/spec/invidious/videos/regular_videos_extract_spec.cr @@ -67,7 +67,7 @@ Spectator.describe "parse_video_info" do # Video metadata expect(info["genre"].as_s).to eq("Entertainment") - expect(info["genreUcid"].as_s).to be_empty + expect(info["genreUcid"].as_s).to be_nil expect(info["license"].as_s).to be_empty # Author infos @@ -151,7 +151,7 @@ Spectator.describe "parse_video_info" do # Video metadata expect(info["genre"].as_s).to eq("Music") - expect(info["genreUcid"].as_s).to be_empty + expect(info["genreUcid"].as_s).to be_nil expect(info["license"].as_s).to be_empty # Author infos diff --git a/spec/invidious/videos/scheduled_live_extract_spec.cr b/spec/invidious/videos/scheduled_live_extract_spec.cr index 25e08c51..82dd8f00 100644 --- a/spec/invidious/videos/scheduled_live_extract_spec.cr +++ b/spec/invidious/videos/scheduled_live_extract_spec.cr @@ -94,7 +94,7 @@ Spectator.describe "parse_video_info" do # Video metadata expect(info["genre"].as_s).to eq("Entertainment") - expect(info["genreUcid"].as_s).to be_empty + expect(info["genreUcid"].as_s).to be_nil expect(info["license"].as_s).to be_empty # Author infos diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 5a4a55c3..cdfca02c 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -250,7 +250,7 @@ struct Video end def genre_url : String? - info["genreUcid"]? == "" ? nil : "/channel/#{info["genreUcid"]}" + info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil end def is_vr : Bool? diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 0e1a947c..4bdb2512 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -424,7 +424,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), # Video metadata "genre" => JSON::Any.new(genre.try &.as_s || ""), - "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), + "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s?), "license" => JSON::Any.new(license.try &.as_s || ""), # Music section "music" => JSON.parse(music_list.to_json), -- cgit v1.2.3 From 911dad69358a299b77e14303e570d48960aa0f1d Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 9 Jul 2024 13:25:18 -0400 Subject: Channel: parse subscriber count and channel banner --- src/invidious/channels/about.cr | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index b5a27667..edaf5c12 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -72,6 +72,7 @@ def get_about_info(ucid, locale) : AboutChannel # Raises a KeyError on failure. banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? + banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources") banner = banners.try &.[-1]?.try &.["url"].as_s? # if banner.includes? "channels/c4/default_banner" @@ -147,9 +148,17 @@ def get_about_info(ucid, locale) : AboutChannel end end - sub_count = initdata - .dig?("header", "c4TabbedHeaderRenderer", "subscriberCountText", "simpleText").try &.as_s? - .try { |text| short_text_to_number(text.split(" ")[0]).to_i32 } || 0 + sub_count = 0 + + if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a) + metadata_rows.each do |row| + metadata_part = row.dig?("metadataParts").try &.as_a.find { |i| i.dig?("text", "content").try &.as_s.includes?("subscribers") } + if !metadata_part.nil? + sub_count = short_text_to_number(metadata_part.dig("text", "content").as_s.split(" ")[0]).to_i32 + end + break if sub_count != 0 + end + end AboutChannel.new( ucid: ucid, -- cgit v1.2.3 From 8a90add3106d5dffa1bcd731a69d061844dd890f Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 11 Jul 2024 20:53:40 -0700 Subject: Ameba: Fix Naming/VariableNames Fix Naming/VariableNames in comment renderer Fix Naming/VariableNames in helpers/utils Fix Naming/VariableNames in api/v1/misc.cr --- src/invidious/comments/content.cr | 36 ++++++++++++++++++------------------ src/invidious/helpers/utils.cr | 6 +++--- src/invidious/routes/api/v1/misc.cr | 6 +++--- 3 files changed, 24 insertions(+), 24 deletions(-) (limited to 'src') diff --git a/src/invidious/comments/content.cr b/src/invidious/comments/content.cr index beefd9ad..3e0d41d7 100644 --- a/src/invidious/comments/content.cr +++ b/src/invidious/comments/content.cr @@ -5,35 +5,35 @@ def text_to_parsed_content(text : String) : JSON::Any # In first case line is just a simple node before # check patterns inside line # { 'text': line } - currentNodes = [] of JSON::Any - initialNode = {"text" => line} - currentNodes << (JSON.parse(initialNode.to_json)) + current_nodes = [] of JSON::Any + initial_node = {"text" => line} + current_nodes << (JSON.parse(initial_node.to_json)) # For each match with url pattern, get last node and preserve # last node before create new node with url information # { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } } - line.scan(/https?:\/\/[^ ]*/).each do |urlMatch| + line.scan(/https?:\/\/[^ ]*/).each do |url_match| # Retrieve last node and update node without match - lastNode = currentNodes[currentNodes.size - 1].as_h - splittedLastNode = lastNode["text"].as_s.split(urlMatch[0]) - lastNode["text"] = JSON.parse(splittedLastNode[0].to_json) - currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) + last_node = current_nodes[current_nodes.size - 1].as_h + splitted_last_node = last_node["text"].as_s.split(url_match[0]) + last_node["text"] = JSON.parse(splitted_last_node[0].to_json) + current_nodes[current_nodes.size - 1] = JSON.parse(last_node.to_json) # Create new node with match and navigation infos - currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}} - currentNodes << (JSON.parse(currentNode.to_json)) + current_node = {"text" => url_match[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => url_match[0]}}} + current_nodes << (JSON.parse(current_node.to_json)) # If text remain after match create new simple node with text after match - afterNode = {"text" => splittedLastNode.size > 1 ? splittedLastNode[1] : ""} - currentNodes << (JSON.parse(afterNode.to_json)) + after_node = {"text" => splitted_last_node.size > 1 ? splitted_last_node[1] : ""} + current_nodes << (JSON.parse(after_node.to_json)) end # After processing of matches inside line # Add \n at end of last node for preserve carriage return - lastNode = currentNodes[currentNodes.size - 1].as_h - lastNode["text"] = JSON.parse("#{currentNodes[currentNodes.size - 1]["text"]}\n".to_json) - currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) + last_node = current_nodes[current_nodes.size - 1].as_h + last_node["text"] = JSON.parse("#{current_nodes[current_nodes.size - 1]["text"]}\n".to_json) + current_nodes[current_nodes.size - 1] = JSON.parse(last_node.to_json) # Finally add final nodes to nodes returned - currentNodes.each do |node| + current_nodes.each do |node| nodes << (node) end end @@ -53,8 +53,8 @@ def content_to_comment_html(content, video_id : String? = "") text = HTML.escape(run["text"].as_s) - if navigationEndpoint = run.dig?("navigationEndpoint") - text = parse_link_endpoint(navigationEndpoint, text, video_id) + if navigation_endpoint = run.dig?("navigationEndpoint") + text = parse_link_endpoint(navigation_endpoint, text, video_id) end text = "#{text}" if run["bold"]? diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index e438e3b9..8e9e9a6a 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -52,9 +52,9 @@ def recode_length_seconds(time) end def decode_interval(string : String) : Time::Span - rawMinutes = string.try &.to_i32? + raw_minutes = string.try &.to_i32? - if !rawMinutes + if !raw_minutes hours = /(?\d+)h/.match(string).try &.["hours"].try &.to_i32 hours ||= 0 @@ -63,7 +63,7 @@ def decode_interval(string : String) : Time::Span time = Time::Span.new(hours: hours, minutes: minutes) else - time = Time::Span.new(minutes: rawMinutes) + time = Time::Span.new(minutes: raw_minutes) end return time diff --git a/src/invidious/routes/api/v1/misc.cr b/src/invidious/routes/api/v1/misc.cr index 12942906..52a985b1 100644 --- a/src/invidious/routes/api/v1/misc.cr +++ b/src/invidious/routes/api/v1/misc.cr @@ -177,8 +177,8 @@ module Invidious::Routes::API::V1::Misc begin resolved_url = YoutubeAPI.resolve_url(url.as(String)) endpoint = resolved_url["endpoint"] - pageType = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" - if pageType == "WEB_PAGE_TYPE_UNKNOWN" + page_type = endpoint.dig?("commandMetadata", "webCommandMetadata", "webPageType").try &.as_s || "" + if page_type == "WEB_PAGE_TYPE_UNKNOWN" return error_json(400, "Unknown url") end @@ -194,7 +194,7 @@ module Invidious::Routes::API::V1::Misc json.field "playlistId", sub_endpoint["playlistId"].as_s if sub_endpoint["playlistId"]? json.field "startTimeSeconds", sub_endpoint["startTimeSeconds"].as_i if sub_endpoint["startTimeSeconds"]? json.field "params", params.try &.as_s - json.field "pageType", pageType + json.field "pageType", page_type end end end -- cgit v1.2.3 From 8258062ec512f9adf9523e259fbb0d33552329e9 Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 15 Jul 2024 17:36:00 -0700 Subject: Ameba: Fix Lint/NotNilAfterNoBang --- src/invidious/helpers/signatures.cr | 16 ++++++++-------- src/invidious/routes/api/v1/videos.cr | 4 ++-- src/invidious/user/imports.cr | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index ee09415b..38ded969 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -13,20 +13,20 @@ struct DecryptFunction private def fetch_decrypt_function(id = "CvFH_6DNRCY") document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body - url = document.match(/src="(?\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"] + url = document.match!(/src="(?\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/)["url"] player = YT_POOL.client &.get(url).body - function_name = player.match(/^(?[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"] - function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?[^}]+)}/m).not_nil!["body"] + function_name = player.match!(/^(?[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m)["name"] + function_body = player.match!(/^#{Regex.escape(function_name)}=function\(\w\){(?[^}]+)}/m)["body"] function_body = function_body.split(";")[1..-2] var_name = function_body[0][0, 2] - var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?(.*?))};/).not_nil!["body"] + var_body = player.delete("\n").match!(/var #{Regex.escape(var_name)}={(?(.*?))};/)["body"] operations = {} of String => SigProc var_body.split("},").each do |operation| - op_name = operation.match(/^[^:]+/).not_nil![0] - op_body = operation.match(/\{[^}]+/).not_nil![0] + op_name = operation.match!(/^[^:]+/)[0] + op_body = operation.match!(/\{[^}]+/)[0] case op_body when "{a.reverse()" @@ -42,8 +42,8 @@ struct DecryptFunction function_body.each do |function| function = function.lchop(var_name).delete("[].") - op_name = function.match(/[^\(]+/).not_nil![0] - value = function.match(/\(\w,(?[\d]+)\)/).not_nil!["value"].to_i + op_name = function.match!(/[^\(]+/)[0] + value = function.match!(/\(\w,(?[\d]+)\)/)["value"].to_i decrypt_function << {operations[op_name], value} end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index faff2f59..4fc6a205 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -215,7 +215,7 @@ module Invidious::Routes::API::V1::Videos storyboard[:storyboard_count].times do |i| url = storyboard[:url] - authority = /(i\d?).ytimg.com/.match(url).not_nil![1]? + authority = /(i\d?).ytimg.com/.match!(url)[1]? url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") url = "#{HOST_URL}/sb/#{authority}/#{url}" @@ -250,7 +250,7 @@ module Invidious::Routes::API::V1::Videos if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id)) annotations = cached_annotation.annotations else - index = CHARS_SAFE.index(id[0]).not_nil!.to_s.rjust(2, '0') + index = CHARS_SAFE.index!(id[0]).to_s.rjust(2, '0') # IA doesn't handle leading hyphens, # so we use https://archive.org/details/youtubeannotations_64 diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 108f2ccc..29b59293 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -182,7 +182,7 @@ struct Invidious::User if is_opml?(type, extension) subscriptions = XML.parse(body) user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| - channel["xmlUrl"].match(/UC[a-zA-Z0-9_-]{22}/).not_nil![0] + channel["xmlUrl"].match!(/UC[a-zA-Z0-9_-]{22}/)[0] end elsif extension == "json" || type == "application/json" subscriptions = JSON.parse(body) -- cgit v1.2.3 From fa50e0abf40f120a021229dfdff0d3aff7f3cfe6 Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 17 Jul 2024 12:21:48 -0700 Subject: Simplify last_node retrieval Co-authored-by: Samantaz Fox --- src/invidious/comments/content.cr | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/comments/content.cr b/src/invidious/comments/content.cr index 3e0d41d7..1f55bfe6 100644 --- a/src/invidious/comments/content.cr +++ b/src/invidious/comments/content.cr @@ -14,10 +14,10 @@ def text_to_parsed_content(text : String) : JSON::Any # { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } } line.scan(/https?:\/\/[^ ]*/).each do |url_match| # Retrieve last node and update node without match - last_node = current_nodes[current_nodes.size - 1].as_h + last_node = current_nodes[-1].as_h splitted_last_node = last_node["text"].as_s.split(url_match[0]) last_node["text"] = JSON.parse(splitted_last_node[0].to_json) - current_nodes[current_nodes.size - 1] = JSON.parse(last_node.to_json) + current_nodes[-1] = JSON.parse(last_node.to_json) # Create new node with match and navigation infos current_node = {"text" => url_match[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => url_match[0]}}} current_nodes << (JSON.parse(current_node.to_json)) @@ -28,9 +28,9 @@ def text_to_parsed_content(text : String) : JSON::Any # After processing of matches inside line # Add \n at end of last node for preserve carriage return - last_node = current_nodes[current_nodes.size - 1].as_h - last_node["text"] = JSON.parse("#{current_nodes[current_nodes.size - 1]["text"]}\n".to_json) - current_nodes[current_nodes.size - 1] = JSON.parse(last_node.to_json) + last_node = current_nodes[-1].as_h + last_node["text"] = JSON.parse("#{last_node["text"]}\n".to_json) + current_nodes[-1] = JSON.parse(last_node.to_json) # Finally add final nodes to nodes returned current_nodes.each do |node| -- cgit v1.2.3 From fad0a4f52d7c9b2f9310c1c52156560ddd3f36a3 Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 17 Jul 2024 12:39:40 -0700 Subject: Ameba: Fix Lint/UselessAssign --- spec/invidious/search/iv_filters_spec.cr | 1 - src/invidious/channels/channels.cr | 2 +- src/invidious/frontend/misc.cr | 4 ++-- src/invidious/helpers/handlers.cr | 2 +- src/invidious/user/imports.cr | 2 +- src/invidious/videos.cr | 4 ---- src/invidious/yt_backend/connection_pool.cr | 2 +- src/invidious/yt_backend/extractors.cr | 1 - src/invidious/yt_backend/extractors_utils.cr | 2 +- 9 files changed, 7 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/spec/invidious/search/iv_filters_spec.cr b/spec/invidious/search/iv_filters_spec.cr index b0897a63..3cefafa1 100644 --- a/spec/invidious/search/iv_filters_spec.cr +++ b/spec/invidious/search/iv_filters_spec.cr @@ -301,7 +301,6 @@ Spectator.describe Invidious::Search::Filters do it "Encodes features filter (single)" do Invidious::Search::Filters::Features.each do |value| - string = described_class.format_features(value) filters = described_class.new(features: value) expect("#{filters.to_iv_params}") diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index be739673..29546e38 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -232,7 +232,7 @@ def fetch_channel(ucid, pull_all_videos : Bool) id: video_id, title: title, published: published, - updated: Time.utc, + updated: updated, ucid: ucid, author: author, length_seconds: length_seconds, diff --git a/src/invidious/frontend/misc.cr b/src/invidious/frontend/misc.cr index 43ba9f5c..7a6cf79d 100644 --- a/src/invidious/frontend/misc.cr +++ b/src/invidious/frontend/misc.cr @@ -6,9 +6,9 @@ module Invidious::Frontend::Misc if prefs.automatic_instance_redirect current_page = env.get?("current_page").as(String) - redirect_url = "/redirect?referer=#{current_page}" + return "/redirect?referer=#{current_page}" else - redirect_url = "https://redirect.invidious.io#{env.request.resource}" + return "https://redirect.invidious.io#{env.request.resource}" end end end diff --git a/src/invidious/helpers/handlers.cr b/src/invidious/helpers/handlers.cr index 174f620d..f3e3b951 100644 --- a/src/invidious/helpers/handlers.cr +++ b/src/invidious/helpers/handlers.cr @@ -97,7 +97,7 @@ class AuthHandler < Kemal::Handler if token = env.request.headers["Authorization"]? token = JSON.parse(URI.decode_www_form(token.lchop("Bearer "))) session = URI.decode_www_form(token["session"].as_s) - scopes, expire, signature = validate_request(token, session, env.request, HMAC_KEY, nil) + scopes, _, _ = validate_request(token, session, env.request, HMAC_KEY, nil) if email = Invidious::Database::SessionIDs.select_email(session) user = Invidious::Database::Users.select!(email: email) diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 108f2ccc..4a3e1259 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -124,7 +124,7 @@ struct Invidious::User playlist = create_playlist(title, privacy, user) Invidious::Database::Playlists.update_description(playlist.id, description) - videos = item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| + item["videos"]?.try &.as_a?.try &.each_with_index do |video_id, idx| if idx > CONFIG.playlist_length_limit raise InfoException.new("Playlist cannot have more than #{CONFIG.playlist_length_limit} videos") end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index c218b4ef..9a357376 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -394,10 +394,6 @@ end def fetch_video(id, region) info = extract_video_info(video_id: id) - allowed_regions = info - .dig?("microformat", "playerMicroformatRenderer", "availableCountries") - .try &.as_a.map &.as_s || [] of String - if reason = info["reason"]? if reason == "Video unavailable" raise NotFoundException.new(reason.as_s || "") diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index d3dbcc0e..c0356c59 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -24,7 +24,7 @@ struct YoutubeConnectionPool @pool = build_pool() end - def client(&block) + def client(&) conn = pool.checkout begin response = yield conn diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 0e72957e..0f4f59b8 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -109,7 +109,6 @@ private module Parsers end live_now = false - paid = false premium = false premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index 11d95958..c83a2de5 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -83,5 +83,5 @@ end def extract_selected_tab(tabs) # Extract the selected tab from the array of tabs Youtube returns - return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"] + return tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"] end -- cgit v1.2.3 From 53223f99b03ac1a51cb35f7c33d4939083dc6f1a Mon Sep 17 00:00:00 2001 From: Emilien Devos <4016501+unixfox@users.noreply.github.com> Date: Wed, 24 Jul 2024 19:28:47 +0200 Subject: Add ability to set po_token and visitordata ID --- config/config.example.yml | 12 ++++++++++++ src/invidious/config.cr | 5 +++++ src/invidious/videos/parser.cr | 11 ++++++++--- src/invidious/yt_backend/youtube_api.cr | 11 +++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 38085a20..f666405e 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -173,6 +173,18 @@ https_only: false ## # use_innertube_for_captions: false +## +## Send Google session informations. This is useful when Invidious is blocked +## by the message "This helps protect our community." +## See https://github.com/iv-org/invidious/issues/4734. +## +## Warning: These strings gives much more identifiable information to Google! +## +## Accepted values: String +## Default: +## +# po_token: "" +# visitor_data: "" # ----------------------------- # Logging diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 09c2168b..5340d4f5 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -130,6 +130,11 @@ class Config # Use Innertube's transcripts API instead of timedtext for closed captions property use_innertube_for_captions : Bool = false + # visitor data ID for Google session + property visitor_data : String? = nil + # poToken for passing bot attestation + property po_token : String? = nil + # Saved cookies in "name1=value1; name2=value2..." format @[YAML::Field(converter: Preferences::StringToCookies)] property cookies : HTTP::Cookies = HTTP::Cookies.new diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 4bdb2512..95fa3d79 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -55,7 +55,7 @@ def extract_video_info(video_id : String) client_config = YoutubeAPI::ClientConfig.new # Fetch data from the player endpoint - player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) + player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config) playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s @@ -102,7 +102,9 @@ def extract_video_info(video_id : String) new_player_response = nil - if reason.nil? + # Don't use Android client if po_token is passed because po_token doesn't + # work for Android client. + if reason.nil? && CONFIG.po_token.nil? # Fetch the video streams using an Android client in order to get the # decrypted URLs and maybe fix throttling issues (#2194). See the # following issue for an explanation about decrypted URLs: @@ -112,7 +114,10 @@ def extract_video_info(video_id : String) end # Last hope - if new_player_response.nil? + # Only trigger if reason found and po_token or didn't work wth Android client. + # TvHtml5ScreenEmbed now requires sig helper for it to work but po_token is not required + # if the IP address is not blocked. + if CONFIG.po_token && reason || CONFIG.po_token.nil? && new_player_response.nil? client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed new_player_response = try_fetch_streaming_data(video_id, client_config) end diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index c8b037c8..0efbe949 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -320,6 +320,10 @@ module YoutubeAPI client_context["client"]["platform"] = platform end + if CONFIG.visitor_data.is_a?(String) + client_context["client"]["visitorData"] = CONFIG.visitor_data.as(String) + end + return client_context end @@ -467,6 +471,9 @@ module YoutubeAPI "html5Preference": "HTML5_PREF_WANTS", }, }, + "serviceIntegrityDimensions" => { + "poToken" => CONFIG.po_token, + }, } # Append the additional parameters if those were provided @@ -599,6 +606,10 @@ module YoutubeAPI headers["User-Agent"] = user_agent end + if CONFIG.visitor_data.is_a?(String) + headers["X-Goog-Visitor-Id"] = CONFIG.visitor_data.as(String) + end + # Logging LOGGER.debug("YoutubeAPI: Using endpoint: \"#{endpoint}\"") LOGGER.trace("YoutubeAPI: ClientConfig: #{client_config}") -- cgit v1.2.3 From 3415507e4a9545addc21e4a985a6c0097ba9cf8b Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 24 Jul 2024 19:48:34 -0700 Subject: Ameba: undo Lint/NotNilAfterNoBang in signatures.cr File is set to be removed with #4772 --- src/invidious/helpers/signatures.cr | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index 38ded969..ee09415b 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -13,20 +13,20 @@ struct DecryptFunction private def fetch_decrypt_function(id = "CvFH_6DNRCY") document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body - url = document.match!(/src="(?\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/)["url"] + url = document.match(/src="(?\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"] player = YT_POOL.client &.get(url).body - function_name = player.match!(/^(?[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m)["name"] - function_body = player.match!(/^#{Regex.escape(function_name)}=function\(\w\){(?[^}]+)}/m)["body"] + function_name = player.match(/^(?[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"] + function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?[^}]+)}/m).not_nil!["body"] function_body = function_body.split(";")[1..-2] var_name = function_body[0][0, 2] - var_body = player.delete("\n").match!(/var #{Regex.escape(var_name)}={(?(.*?))};/)["body"] + var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?(.*?))};/).not_nil!["body"] operations = {} of String => SigProc var_body.split("},").each do |operation| - op_name = operation.match!(/^[^:]+/)[0] - op_body = operation.match!(/\{[^}]+/)[0] + op_name = operation.match(/^[^:]+/).not_nil![0] + op_body = operation.match(/\{[^}]+/).not_nil![0] case op_body when "{a.reverse()" @@ -42,8 +42,8 @@ struct DecryptFunction function_body.each do |function| function = function.lchop(var_name).delete("[].") - op_name = function.match!(/[^\(]+/)[0] - value = function.match!(/\(\w,(?[\d]+)\)/)["value"].to_i + op_name = function.match(/[^\(]+/).not_nil![0] + value = function.match(/\(\w,(?[\d]+)\)/).not_nil!["value"].to_i decrypt_function << {operations[op_name], value} end -- cgit v1.2.3 From 636a6d0be27cea0c0e255dfe2d0c367edc0a3fba Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 24 Jul 2024 19:57:54 -0700 Subject: Ameba: Fix Lint/UnusedArgument --- src/invidious/routes/account.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/account.cr b/src/invidious/routes/account.cr index 9d930841..dd65e7a6 100644 --- a/src/invidious/routes/account.cr +++ b/src/invidious/routes/account.cr @@ -53,7 +53,7 @@ module Invidious::Routes::Account return error_template(401, "Password is a required field") end - new_passwords = env.params.body.select { |k, v| k.match(/^new_password\[\d+\]$/) }.map { |k, v| v } + new_passwords = env.params.body.select { |k, _| k.match(/^new_password\[\d+\]$/) }.map { |_, v| v } if new_passwords.size <= 1 || new_passwords.uniq.size != 1 return error_template(400, "New passwords must match") @@ -240,7 +240,7 @@ module Invidious::Routes::Account return error_template(400, ex) end - scopes = env.params.body.select { |k, v| k.match(/^scopes\[\d+\]$/) }.map { |k, v| v } + scopes = env.params.body.select { |k, _| k.match(/^scopes\[\d+\]$/) }.map { |_, v| v } callback_url = env.params.body["callbackUrl"]? expire = env.params.body["expire"]?.try &.to_i? -- cgit v1.2.3 From c8fb75e6fd314bc1241bf256a2b897d409f79f42 Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 24 Jul 2024 19:59:20 -0700 Subject: Ameba: Fix Lint/UnusedBlockArgument --- src/invidious/yt_backend/connection_pool.cr | 4 ++-- src/invidious/yt_backend/extractors.cr | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index d3dbcc0e..0ac785e6 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -24,7 +24,7 @@ struct YoutubeConnectionPool @pool = build_pool() end - def client(&block) + def client(&) conn = pool.checkout begin response = yield conn @@ -69,7 +69,7 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false) return client end -def make_client(url : URI, region = nil, force_resolve : Bool = false, &block) +def make_client(url : URI, region = nil, force_resolve : Bool = false, &) client = make_client(url, region, force_resolve) begin yield client diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 0e72957e..57a5dc3d 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -856,7 +856,7 @@ end # # This function yields the container so that items can be parsed separately. # -def extract_items(initial_data : InitialData, &block) +def extract_items(initial_data : InitialData, &) if unpackaged_data = initial_data["contents"]?.try &.as_h elsif unpackaged_data = initial_data["response"]?.try &.as_h elsif unpackaged_data = initial_data.dig?("onResponseReceivedActions", 1).try &.as_h -- cgit v1.2.3 From 0db3b830b7d838f34710d7625d118a6aec821451 Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 24 Jul 2024 20:03:41 -0700 Subject: Ameba: Fix Lint/HashDuplicatedKey --- src/invidious/helpers/i18next.cr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr index 9f4077e1..04033e8c 100644 --- a/src/invidious/helpers/i18next.cr +++ b/src/invidious/helpers/i18next.cr @@ -95,7 +95,6 @@ module I18next::Plurals "hr" => PluralForms::Special_Hungarian_Serbian, "it" => PluralForms::Special_Spanish_Italian, "pt" => PluralForms::Special_French_Portuguese, - "pt" => PluralForms::Special_French_Portuguese, "sr" => PluralForms::Special_Hungarian_Serbian, } -- cgit v1.2.3 From 205f988491886c81f0179f08c23691201e2ae172 Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 24 Jul 2024 20:04:44 -0700 Subject: Ameba: Fix Naming/MethodNames --- src/invidious/helpers/i18next.cr | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr index 04033e8c..c82a1f08 100644 --- a/src/invidious/helpers/i18next.cr +++ b/src/invidious/helpers/i18next.cr @@ -261,9 +261,9 @@ module I18next::Plurals when .special_hebrew? then return special_hebrew(count) when .special_odia? then return special_odia(count) # Mixed v3/v4 forms - when .special_spanish_italian? then return special_cldr_Spanish_Italian(count) - when .special_french_portuguese? then return special_cldr_French_Portuguese(count) - when .special_hungarian_serbian? then return special_cldr_Hungarian_Serbian(count) + when .special_spanish_italian? then return special_cldr_spanish_italian(count) + when .special_french_portuguese? then return special_cldr_french_portuguese(count) + when .special_hungarian_serbian? then return special_cldr_hungarian_serbian(count) else # default, if nothing matched above return 0_u8 @@ -534,7 +534,7 @@ module I18next::Plurals # # This rule is mostly compliant to CLDR v42 # - def self.special_cldr_Spanish_Italian(count : Int) : UInt8 + def self.special_cldr_spanish_italian(count : Int) : UInt8 return 0_u8 if (count == 1) # one return 1_u8 if (count != 0 && count % 1_000_000 == 0) # many return 2_u8 # other @@ -544,7 +544,7 @@ module I18next::Plurals # # This rule is mostly compliant to CLDR v42 # - def self.special_cldr_French_Portuguese(count : Int) : UInt8 + def self.special_cldr_french_portuguese(count : Int) : UInt8 return 0_u8 if (count == 0 || count == 1) # one return 1_u8 if (count % 1_000_000 == 0) # many return 2_u8 # other @@ -554,7 +554,7 @@ module I18next::Plurals # # This rule is mostly compliant to CLDR v42 # - def self.special_cldr_Hungarian_Serbian(count : Int) : UInt8 + def self.special_cldr_hungarian_serbian(count : Int) : UInt8 n_mod_10 = count % 10 n_mod_100 = count % 100 -- cgit v1.2.3 From 63a729998bbb4196efe9bcaedb5c58863e8f3d57 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 3 Jul 2024 21:13:29 +0200 Subject: Misc: Sync crystal overrides with current stdlib --- src/invidious/helpers/crystal_class_overrides.cr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/crystal_class_overrides.cr b/src/invidious/helpers/crystal_class_overrides.cr index bf56d826..fec3f62c 100644 --- a/src/invidious/helpers/crystal_class_overrides.cr +++ b/src/invidious/helpers/crystal_class_overrides.cr @@ -3,9 +3,9 @@ # IPv6 addresses. # class TCPSocket - def initialize(host : String, port, dns_timeout = nil, connect_timeout = nil, family = Socket::Family::UNSPEC) + def initialize(host, port, dns_timeout = nil, connect_timeout = nil, blocking = false, family = Socket::Family::UNSPEC) Addrinfo.tcp(host, port, timeout: dns_timeout, family: family) do |addrinfo| - super(addrinfo.family, addrinfo.type, addrinfo.protocol) + super(addrinfo.family, addrinfo.type, addrinfo.protocol, blocking) connect(addrinfo, timeout: connect_timeout) do |error| close error @@ -26,7 +26,7 @@ class HTTP::Client end hostname = @host.starts_with?('[') && @host.ends_with?(']') ? @host[1..-2] : @host - io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, @family + io = TCPSocket.new hostname, @port, @dns_timeout, @connect_timeout, family: @family io.read_timeout = @read_timeout if @read_timeout io.write_timeout = @write_timeout if @write_timeout io.sync = false @@ -35,7 +35,7 @@ class HTTP::Client if tls = @tls tcp_socket = io begin - io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host) + io = OpenSSL::SSL::Socket::Client.new(tcp_socket, context: tls, sync_close: true, hostname: @host.rchop('.')) rescue exc # don't leak the TCP socket when the SSL connection failed tcp_socket.close -- cgit v1.2.3 From a845752fff1c5dd336e7e4a758691a874aa1d3ea Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 3 Jul 2024 18:24:08 +0200 Subject: Jobs: Remove the signature function update job --- config/config.example.yml | 15 --------------- src/invidious.cr | 4 ---- src/invidious/config.cr | 2 -- src/invidious/jobs/update_decrypt_function_job.cr | 14 -------------- 4 files changed, 35 deletions(-) delete mode 100644 src/invidious/jobs/update_decrypt_function_job.cr (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 38085a20..142fdfb7 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -343,21 +343,6 @@ full_refresh: false ## feed_threads: 1 -## -## Enable/Disable the polling job that keeps the decryption -## function (for "secured" videos) up to date. -## -## Note: This part of the code generate a small amount of data every minute. -## This may not be desired if you have bandwidth limits set by your ISP. -## -## Note 2: This part of the code is currently broken, so changing -## this setting has no impact. -## -## Accepted values: true, false -## Default: false -## -#decrypt_polling: false - jobs: diff --git a/src/invidious.cr b/src/invidious.cr index e0bd0101..c667ff1a 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -164,10 +164,6 @@ if CONFIG.feed_threads > 0 end DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling) -if CONFIG.decrypt_polling - Invidious::Jobs.register Invidious::Jobs::UpdateDecryptFunctionJob.new -end - if CONFIG.statistics_enabled Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE) end diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 09c2168b..da911e04 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -74,8 +74,6 @@ class Config # Database configuration using 12-Factor "Database URL" syntax @[YAML::Field(converter: Preferences::URIConverter)] property database_url : URI = URI.parse("") - # Use polling to keep decryption function up to date - property decrypt_polling : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel property full_refresh : Bool = false diff --git a/src/invidious/jobs/update_decrypt_function_job.cr b/src/invidious/jobs/update_decrypt_function_job.cr deleted file mode 100644 index 6fa0ae1b..00000000 --- a/src/invidious/jobs/update_decrypt_function_job.cr +++ /dev/null @@ -1,14 +0,0 @@ -class Invidious::Jobs::UpdateDecryptFunctionJob < Invidious::Jobs::BaseJob - def begin - loop do - begin - DECRYPT_FUNCTION.update_decrypt_function - rescue ex - LOGGER.error("UpdateDecryptFunctionJob : #{ex.message}") - ensure - sleep 1.minute - Fiber.yield - end - end - end -end -- cgit v1.2.3 From 56a7488161428bb53d025246b9890f3f65edb3d4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 1 Jul 2024 22:24:24 +0200 Subject: Helpers: Add inv_sig_helper client --- src/invidious/helpers/sig_helper.cr | 303 ++++++++++++++++++++++++++++++++++++ 1 file changed, 303 insertions(+) create mode 100644 src/invidious/helpers/sig_helper.cr (limited to 'src') diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr new file mode 100644 index 00000000..622f0b38 --- /dev/null +++ b/src/invidious/helpers/sig_helper.cr @@ -0,0 +1,303 @@ +require "uri" +require "socket" +require "socket/tcp_socket" +require "socket/unix_socket" + +private alias NetworkEndian = IO::ByteFormat::NetworkEndian + +class Invidious::SigHelper + enum UpdateStatus + Updated + UpdateNotRequired + Error + end + + # ------------------- + # Payload types + # ------------------- + + abstract struct Payload + end + + struct StringPayload < Payload + getter value : String + + def initialize(str : String) + raise Exception.new("SigHelper: String can't be empty") if str.empty? + @value = str + end + + def self.from_io(io : IO) + size = io.read_bytes(UInt16, NetworkEndian) + if size == 0 # Error code + raise Exception.new("SigHelper: Server encountered an error") + end + + if str = io.gets(limit: size) + return self.new(str) + else + raise Exception.new("SigHelper: Can't read string from socket") + end + end + + def self.to_io(io : IO) + # `.to_u16` raises if there is an overflow during the conversion + io.write_bytes(@value.bytesize.to_u16, NetworkEndian) + io.write(@value.to_slice) + end + end + + private enum Opcode + FORCE_UPDATE = 0 + DECRYPT_N_SIGNATURE = 1 + DECRYPT_SIGNATURE = 2 + GET_SIGNATURE_TIMESTAMP = 3 + GET_PLAYER_STATUS = 4 + end + + private struct Request + def initialize(@opcode : Opcode, @payload : Payload?) + end + end + + # ---------------------- + # High-level functions + # ---------------------- + + module Client + # Forces the server to re-fetch the YouTube player, and extract the necessary + # components from it (nsig function code, sig function code, signature timestamp). + def force_update : UpdateStatus + request = Request.new(Opcode::FORCE_UPDATE, nil) + + value = send_request(request) do |io| + io.read_bytes(UInt16, NetworkEndian) + end + + case value + when 0x0000 then return UpdateStatus::Error + when 0xFFFF then return UpdateStatus::UpdateNotRequired + when 0xF44F then return UpdateStatus::Updated + else + raise Exception.new("SigHelper: Invalid status code received") + end + end + + # Decrypt a provided n signature using the server's current nsig function + # code, and return the result (or an error). + def decrypt_n_param(n : String) : String + request = Request.new(Opcode::DECRYPT_N_SIGNATURE, StringPayload.new(n)) + + n_dec = send_request(request) do |io| + StringPayload.from_io(io).string + rescue ex + LOGGER.debug(ex.message) + nil + end + + return n_dec + end + + # Decrypt a provided s signature using the server's current sig function + # code, and return the result (or an error). + def decrypt_sig(sig : String) : String? + request = Request.new(Opcode::DECRYPT_SIGNATURE, StringPayload.new(sig)) + + sig_dec = send_request(request) do |io| + StringPayload.from_io(io).string + rescue ex + LOGGER.debug(ex.message) + nil + end + + return sig_dec + end + + # Return the signature timestamp from the server's current player + def get_sts : UInt64? + request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil) + + return send_request(request) do |io| + io.read_bytes(UInt64, NetworkEndian) + end + end + + # Return the signature timestamp from the server's current player + def get_player : UInt32? + request = Request.new(Opcode::GET_PLAYER_STATUS, nil) + + send_request(request) do |io| + has_player = io.read_bytes(UInt8) == 0xFF + player_version = io.read_bytes(UInt32, NetworkEndian) + end + + return has_player ? player_version : nil + end + + private def send_request(request : Request, &block : IO) + channel = Multiplexor.send(request) + data_io = channel.receive + return yield data_io + rescue ex + LOGGER.debug(ex.message) + return nil + end + end + + # --------------------- + # Low level functions + # --------------------- + + class Multiplexor + alias TransactionID = UInt32 + record Transaction, channel = ::Channel(Bytes).new + + @prng = Random.new + @mutex = Mutex.new + @queue = {} of TransactionID => Transaction + + @conn : Connection + + INSTANCE = new + + def initialize + @conn = Connection.new + listen + end + + def initialize(url : String) + @conn = Connection.new(url) + listen + end + + def listen : Nil + raise "Socket is closed" if @conn.closed? + + # TODO: reopen socket if unexpectedly closed + spawn do + loop do + receive_data + Fiber.sleep + end + end + end + + def self.send(request : Request) + transaction = Transaction.new + transaction_id = @prng.rand(TransactionID) + + # Add transaction to queue + @mutex.synchronize do + # On a 64-bits random integer, this should never happen. Though, just in case, ... + if @queue[transaction_id]? + raise Exception.new("SigHelper: Duplicate transaction ID! You got a shiny pokemon!") + end + + @queue[transaction_id] = transaction + end + + write_packet(transaction_id, request) + + return transaction.channel + end + + def receive_data : Payload + # Read a single packet from socker + transaction_id, data_io = read_packet + + # Remove transaction from queue + @mutex.synchronize do + transaction = @queue.delete(transaction_id) + end + + # Send data to the channel + transaction.channel.send(data) + end + + # Read a single packet from the socket + private def read_packet : {TransactionID, IO} + # Header + transaction_id = @conn.read_u32 + length = conn.read_u32 + + if length > 67_000 + raise Exception.new("SigHelper: Packet longer than expected (#{length})") + end + + # Payload + data_io = IO::Memory.new(1024) + IO.copy(@conn, data_io, limit: length) + + # data = Bytes.new() + # conn.read(data) + + return transaction_id, data_io + end + + # Write a single packet to the socket + private def write_packet(transaction_id : TransactionID, request : Request) + @conn.write_int(request.opcode) + @conn.write_int(transaction_id) + request.payload.to_io(@conn) + end + end + + class Connection + @socket : UNIXSocket | TCPSocket + @mutex = Mutex.new + + def initialize(host_or_path : String) + if host_or_path.empty? + host_or_path = default_path + + begin + case host_or_path + when.starts_with?('/') + @socket = UNIXSocket.new(host_or_path) + when .starts_with?("tcp://") + uri = URI.new(host_or_path) + @socket = TCPSocket.new(uri.host, uri.port) + else + uri = URI.new("tcp://#{host_or_path}") + @socket = TCPSocket.new(uri.host, uri.port) + end + + socket.sync = false + rescue ex + raise ConnectionError.new("Connection error", cause: ex) + end + end + + private default_path + return "/tmp/inv_sig_helper.sock" + end + + def closed? : Bool + return @socket.closed? + end + + def close : Nil + if @socket.closed? + raise Exception.new("SigHelper: Can't close socket, it's already closed") + else + @socket.close + end + end + + def gets(*args, **options) + @socket.gets(*args, **options) + end + + def read_bytes(*args, **options) + @socket.read_bytes(*args, **options) + end + + def write(*args, **options) + @socket.write(*args, **options) + end + + def write_bytes(*args, **options) + @socket.write_bytes(*args, **options) + end + end +end -- cgit v1.2.3 From ec8b7916fa4b90f99a880abc6f7d7e7b2ca2919b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 3 Jul 2024 18:22:32 +0200 Subject: Videos: Make use of the video decoding --- src/invidious.cr | 1 - src/invidious/helpers/signatures.cr | 83 +++++++++---------------------------- src/invidious/videos.cr | 65 ++++++++++++++++++++--------- 3 files changed, 64 insertions(+), 85 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index c667ff1a..0c53197d 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -163,7 +163,6 @@ if CONFIG.feed_threads > 0 Invidious::Jobs.register Invidious::Jobs::RefreshFeedsJob.new(PG_DB) end -DECRYPT_FUNCTION = DecryptFunction.new(CONFIG.decrypt_polling) if CONFIG.statistics_enabled Invidious::Jobs.register Invidious::Jobs::StatisticsRefreshJob.new(PG_DB, SOFTWARE) end diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index ee09415b..3b5c99eb 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -1,73 +1,28 @@ -alias SigProc = Proc(Array(String), Int32, Array(String)) +require "http/params" +require "./sig_helper" -struct DecryptFunction - @decrypt_function = [] of {SigProc, Int32} - @decrypt_time = Time.monotonic +struct Invidious::DecryptFunction + @last_update = Time.monotonic - 42.days - def initialize(@use_polling = true) + def initialize + self.check_update end - def update_decrypt_function - @decrypt_function = fetch_decrypt_function - end - - private def fetch_decrypt_function(id = "CvFH_6DNRCY") - document = YT_POOL.client &.get("/watch?v=#{id}&gl=US&hl=en").body - url = document.match(/src="(?\/s\/player\/[^\/]+\/player_ias[^\/]+\/en_US\/base.js)"/).not_nil!["url"] - player = YT_POOL.client &.get(url).body - - function_name = player.match(/^(?[^=]+)=function\(\w\){\w=\w\.split\(""\);[^\. ]+\.[^( ]+/m).not_nil!["name"] - function_body = player.match(/^#{Regex.escape(function_name)}=function\(\w\){(?[^}]+)}/m).not_nil!["body"] - function_body = function_body.split(";")[1..-2] - - var_name = function_body[0][0, 2] - var_body = player.delete("\n").match(/var #{Regex.escape(var_name)}={(?(.*?))};/).not_nil!["body"] - - operations = {} of String => SigProc - var_body.split("},").each do |operation| - op_name = operation.match(/^[^:]+/).not_nil![0] - op_body = operation.match(/\{[^}]+/).not_nil![0] - - case op_body - when "{a.reverse()" - operations[op_name] = ->(a : Array(String), _b : Int32) { a.reverse } - when "{a.splice(0,b)" - operations[op_name] = ->(a : Array(String), b : Int32) { a.delete_at(0..(b - 1)); a } - else - operations[op_name] = ->(a : Array(String), b : Int32) { c = a[0]; a[0] = a[b % a.size]; a[b % a.size] = c; a } - end - end - - decrypt_function = [] of {SigProc, Int32} - function_body.each do |function| - function = function.lchop(var_name).delete("[].") - - op_name = function.match(/[^\(]+/).not_nil![0] - value = function.match(/\(\w,(?[\d]+)\)/).not_nil!["value"].to_i - - decrypt_function << {operations[op_name], value} + def check_update + now = Time.monotonic + if (now - @last_update) > 60.seconds + LOGGER.debug("Signature: Player might be outdated, updating") + Invidious::SigHelper::Client.force_update + @last_update = Time.monotonic end - - return decrypt_function end - def decrypt_signature(fmt : Hash(String, JSON::Any)) - return "" if !fmt["s"]? || !fmt["sp"]? - - sp = fmt["sp"].as_s - sig = fmt["s"].as_s.split("") - if !@use_polling - now = Time.monotonic - if now - @decrypt_time > 60.seconds || @decrypt_function.size == 0 - @decrypt_function = fetch_decrypt_function - @decrypt_time = Time.monotonic - end - end - - @decrypt_function.each do |proc, value| - sig = proc.call(sig, value) - end - - return "&#{sp}=#{sig.join("")}" + def decrypt_signature(str : String) : String? + self.check_update + return SigHelper::Client.decrypt_sig(str) + rescue ex + LOGGER.debug(ex.message || "Signature: Unknown error") + LOGGER.trace(ex.inspect_with_backtrace) + return nil end end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index cdfca02c..4e705556 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1,3 +1,5 @@ +private DECRYPT_FUNCTION = IV::DecryptFunction.new + enum VideoType Video Livestream @@ -98,20 +100,47 @@ struct Video # Methods for parsing streaming data + def convert_url(fmt) + if cfr = fmt["signatureCipher"]?.try { |h| HTTP::Params.parse(h.as_s) } + sp = cfr["sp"] + url = URI.parse(cfr["url"]) + params = url.query_params + + LOGGER.debug("Videos: Decoding '#{cfr}'") + + unsig = DECRYPT_FUNCTION.decrypt_signature(cfr["s"]) + params[sp] = unsig if unsig + else + url = URI.parse(fmt["url"].as_s) + params = url.query_params + end + + n = DECRYPT_FUNCTION.decrypt_nsig(params["n"]) + params["n"] = n if n + + params["host"] = url.host.not_nil! + if region = self.info["region"]?.try &.as_s + params["region"] = region + end + + url.query_params = params + LOGGER.trace("Videos: new url is '#{url}'") + + return url.to_s + rescue ex + LOGGER.debug("Videos: Error when parsing video URL") + LOGGER.trace(ex.inspect_with_backtrace) + return "" + end + def fmt_stream return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream - fmt_stream = info["streamingData"]?.try &.["formats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) - fmt_stream.each do |fmt| - if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) } - s.each do |k, v| - fmt[k] = JSON::Any.new(v) - end - fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}") - end + fmt_stream = info.dig?("streamingData", "formats") + .try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) - fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") - fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? + fmt_stream.each do |fmt| + fmt["url"] = JSON::Any.new(self.convert_url(fmt)) end fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } @@ -121,21 +150,17 @@ struct Video def adaptive_fmts return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts - fmt_stream = info["streamingData"]?.try &.["adaptiveFormats"]?.try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) - fmt_stream.each do |fmt| - if s = (fmt["cipher"]? || fmt["signatureCipher"]?).try { |h| HTTP::Params.parse(h.as_s) } - s.each do |k, v| - fmt[k] = JSON::Any.new(v) - end - fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}") - end - fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") - fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? + fmt_stream = info.dig("streamingData", "adaptiveFormats") + .try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) + + fmt_stream.each do |fmt| + fmt["url"] = JSON::Any.new(self.convert_url(fmt)) end fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } @adaptive_fmts = fmt_stream + return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) end -- cgit v1.2.3 From b509aa91d5c0955deb4980cd08a93e8d808ee456 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 3 Jul 2024 18:20:35 +0200 Subject: SigHelper: Fix many issues --- src/invidious/helpers/sig_helper.cr | 224 ++++++++++++++++++++---------------- src/invidious/helpers/signatures.cr | 9 ++ 2 files changed, 132 insertions(+), 101 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr index 622f0b38..b8b985d5 100644 --- a/src/invidious/helpers/sig_helper.cr +++ b/src/invidious/helpers/sig_helper.cr @@ -3,6 +3,10 @@ require "socket" require "socket/tcp_socket" require "socket/unix_socket" +{% if flag?(:advanced_debug) %} + require "io/hexdump" +{% end %} + private alias NetworkEndian = IO::ByteFormat::NetworkEndian class Invidious::SigHelper @@ -20,58 +24,63 @@ class Invidious::SigHelper end struct StringPayload < Payload - getter value : String + getter string : String def initialize(str : String) raise Exception.new("SigHelper: String can't be empty") if str.empty? - @value = str + @string = str end - def self.from_io(io : IO) - size = io.read_bytes(UInt16, NetworkEndian) + def self.from_bytes(slice : Bytes) + size = IO::ByteFormat::NetworkEndian.decode(UInt16, slice) if size == 0 # Error code raise Exception.new("SigHelper: Server encountered an error") end - if str = io.gets(limit: size) + if (slice.bytesize - 2) != size + raise Exception.new("SigHelper: String size mismatch") + end + + if str = String.new(slice[2..]) return self.new(str) else raise Exception.new("SigHelper: Can't read string from socket") end end - def self.to_io(io : IO) + def to_io(io) # `.to_u16` raises if there is an overflow during the conversion - io.write_bytes(@value.bytesize.to_u16, NetworkEndian) - io.write(@value.to_slice) + io.write_bytes(@string.bytesize.to_u16, NetworkEndian) + io.write(@string.to_slice) end end private enum Opcode - FORCE_UPDATE = 0 - DECRYPT_N_SIGNATURE = 1 - DECRYPT_SIGNATURE = 2 + FORCE_UPDATE = 0 + DECRYPT_N_SIGNATURE = 1 + DECRYPT_SIGNATURE = 2 GET_SIGNATURE_TIMESTAMP = 3 - GET_PLAYER_STATUS = 4 + GET_PLAYER_STATUS = 4 end - private struct Request - def initialize(@opcode : Opcode, @payload : Payload?) - end - end + private record Request, + opcode : Opcode, + payload : Payload? # ---------------------- # High-level functions # ---------------------- module Client + extend self + # Forces the server to re-fetch the YouTube player, and extract the necessary # components from it (nsig function code, sig function code, signature timestamp). def force_update : UpdateStatus request = Request.new(Opcode::FORCE_UPDATE, nil) - value = send_request(request) do |io| - io.read_bytes(UInt16, NetworkEndian) + value = send_request(request) do |bytes| + IO::ByteFormat::NetworkEndian.decode(UInt16, bytes) end case value @@ -79,20 +88,18 @@ class Invidious::SigHelper when 0xFFFF then return UpdateStatus::UpdateNotRequired when 0xF44F then return UpdateStatus::Updated else - raise Exception.new("SigHelper: Invalid status code received") + code = value.nil? ? "nil" : value.to_s(base: 16) + raise Exception.new("SigHelper: Invalid status code received #{code}") end end # Decrypt a provided n signature using the server's current nsig function # code, and return the result (or an error). - def decrypt_n_param(n : String) : String + def decrypt_n_param(n : String) : String? request = Request.new(Opcode::DECRYPT_N_SIGNATURE, StringPayload.new(n)) - n_dec = send_request(request) do |io| - StringPayload.from_io(io).string - rescue ex - LOGGER.debug(ex.message) - nil + n_dec = send_request(request) do |bytes| + StringPayload.from_bytes(bytes).string end return n_dec @@ -103,11 +110,8 @@ class Invidious::SigHelper def decrypt_sig(sig : String) : String? request = Request.new(Opcode::DECRYPT_SIGNATURE, StringPayload.new(sig)) - sig_dec = send_request(request) do |io| - StringPayload.from_io(io).string - rescue ex - LOGGER.debug(ex.message) - nil + sig_dec = send_request(request) do |bytes| + StringPayload.from_bytes(bytes).string end return sig_dec @@ -117,29 +121,30 @@ class Invidious::SigHelper def get_sts : UInt64? request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil) - return send_request(request) do |io| - io.read_bytes(UInt64, NetworkEndian) + return send_request(request) do |bytes| + IO::ByteFormat::NetworkEndian.decode(UInt64, bytes) end end - # Return the signature timestamp from the server's current player + # Return the current player's version def get_player : UInt32? request = Request.new(Opcode::GET_PLAYER_STATUS, nil) - send_request(request) do |io| - has_player = io.read_bytes(UInt8) == 0xFF - player_version = io.read_bytes(UInt32, NetworkEndian) + send_request(request) do |bytes| + has_player = (bytes[0] == 0xFF) + player_version = IO::ByteFormat::NetworkEndian.decode(UInt32, bytes[1..4]) end return has_player ? player_version : nil end - private def send_request(request : Request, &block : IO) - channel = Multiplexor.send(request) - data_io = channel.receive - return yield data_io + private def send_request(request : Request, &) + channel = Multiplexor::INSTANCE.send(request) + slice = channel.receive + return yield slice rescue ex - LOGGER.debug(ex.message) + LOGGER.debug("SigHelper: Error when sending a request") + LOGGER.trace(ex.inspect_with_backtrace) return nil end end @@ -152,18 +157,13 @@ class Invidious::SigHelper alias TransactionID = UInt32 record Transaction, channel = ::Channel(Bytes).new - @prng = Random.new + @prng = Random.new @mutex = Mutex.new @queue = {} of TransactionID => Transaction @conn : Connection - INSTANCE = new - - def initialize - @conn = Connection.new - listen - end + INSTANCE = new("") def initialize(url : String) @conn = Connection.new(url) @@ -173,22 +173,24 @@ class Invidious::SigHelper def listen : Nil raise "Socket is closed" if @conn.closed? + LOGGER.debug("SigHelper: Multiplexor listening") + # TODO: reopen socket if unexpectedly closed spawn do loop do receive_data - Fiber.sleep + Fiber.yield end end end - def self.send(request : Request) + def send(request : Request) transaction = Transaction.new transaction_id = @prng.rand(TransactionID) # Add transaction to queue @mutex.synchronize do - # On a 64-bits random integer, this should never happen. Though, just in case, ... + # On a 32-bits random integer, this should never happen. Though, just in case, ... if @queue[transaction_id]? raise Exception.new("SigHelper: Duplicate transaction ID! You got a shiny pokemon!") end @@ -201,75 +203,92 @@ class Invidious::SigHelper return transaction.channel end - def receive_data : Payload - # Read a single packet from socker - transaction_id, data_io = read_packet + def receive_data + transaction_id, slice = read_packet - # Remove transaction from queue @mutex.synchronize do - transaction = @queue.delete(transaction_id) + if transaction = @queue.delete(transaction_id) + # Remove transaction from queue and send data to the channel + transaction.channel.send(slice) + LOGGER.trace("SigHelper: Transaction unqueued and data sent to channel") + else + raise Exception.new("SigHelper: Received transaction was not in queue") + end end - - # Send data to the channel - transaction.channel.send(data) end # Read a single packet from the socket - private def read_packet : {TransactionID, IO} + private def read_packet : {TransactionID, Bytes} # Header - transaction_id = @conn.read_u32 - length = conn.read_u32 + transaction_id = @conn.read_bytes(UInt32, NetworkEndian) + length = @conn.read_bytes(UInt32, NetworkEndian) + + LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} / length #{length}") if length > 67_000 raise Exception.new("SigHelper: Packet longer than expected (#{length})") end # Payload - data_io = IO::Memory.new(1024) - IO.copy(@conn, data_io, limit: length) + slice = Bytes.new(length) + @conn.read(slice) if length > 0 - # data = Bytes.new() - # conn.read(data) + LOGGER.trace("SigHelper: payload = #{slice}") + LOGGER.trace("SigHelper: Recv transaction 0x#{transaction_id.to_s(base: 16)} - Done") - return transaction_id, data_io + return transaction_id, slice end # Write a single packet to the socket private def write_packet(transaction_id : TransactionID, request : Request) - @conn.write_int(request.opcode) - @conn.write_int(transaction_id) - request.payload.to_io(@conn) + LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} / opcode #{request.opcode}") + + io = IO::Memory.new(1024) + io.write_bytes(request.opcode.to_u8, NetworkEndian) + io.write_bytes(transaction_id, NetworkEndian) + + if payload = request.payload + payload.to_io(io) + end + + @conn.send(io) + @conn.flush + + LOGGER.trace("SigHelper: Send transaction 0x#{transaction_id.to_s(base: 16)} - Done") end end class Connection @socket : UNIXSocket | TCPSocket - @mutex = Mutex.new + + {% if flag?(:advanced_debug) %} + @io : IO::Hexdump + {% end %} def initialize(host_or_path : String) if host_or_path.empty? - host_or_path = default_path - - begin - case host_or_path - when.starts_with?('/') - @socket = UNIXSocket.new(host_or_path) - when .starts_with?("tcp://") - uri = URI.new(host_or_path) - @socket = TCPSocket.new(uri.host, uri.port) - else - uri = URI.new("tcp://#{host_or_path}") - @socket = TCPSocket.new(uri.host, uri.port) - end + host_or_path = "/tmp/inv_sig_helper.sock" + end - socket.sync = false - rescue ex - raise ConnectionError.new("Connection error", cause: ex) + case host_or_path + when .starts_with?('/') + @socket = UNIXSocket.new(host_or_path) + when .starts_with?("tcp://") + uri = URI.new(host_or_path) + @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) + else + uri = URI.new("tcp://#{host_or_path}") + @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) end - end - private default_path - return "/tmp/inv_sig_helper.sock" + LOGGER.debug("SigHelper: Listening on '#{host_or_path}'") + + {% if flag?(:advanced_debug) %} + @io = IO::Hexdump.new(@socket, output: STDERR, read: true, write: true) + {% end %} + + @socket.sync = false + @socket.blocking = false end def closed? : Bool @@ -284,20 +303,23 @@ class Invidious::SigHelper end end - def gets(*args, **options) - @socket.gets(*args, **options) + def flush(*args, **options) + @socket.flush(*args, **options) end - def read_bytes(*args, **options) - @socket.read_bytes(*args, **options) + def send(*args, **options) + @socket.send(*args, **options) end - def write(*args, **options) - @socket.write(*args, **options) - end - - def write_bytes(*args, **options) - @socket.write_bytes(*args, **options) - end + # Wrap IO functions, with added debug tooling if needed + {% for function in %w(read read_bytes write write_bytes) %} + def {{function.id}}(*args, **options) + {% if flag?(:advanced_debug) %} + @io.{{function.id}}(*args, **options) + {% else %} + @socket.{{function.id}}(*args, **options) + {% end %} + end + {% end %} end end diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index 3b5c99eb..d9aab31c 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -17,6 +17,15 @@ struct Invidious::DecryptFunction end end + def decrypt_nsig(n : String) : String? + self.check_update + return SigHelper::Client.decrypt_n_param(n) + rescue ex + LOGGER.debug(ex.message || "Signature: Unknown error") + LOGGER.trace(ex.inspect_with_backtrace) + return nil + end + def decrypt_signature(str : String) : String? self.check_update return SigHelper::Client.decrypt_sig(str) -- cgit v1.2.3 From 10e5788c212587b7c929c84580aea3e93b2f28ea Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 3 Jul 2024 21:15:13 +0200 Subject: Videos: Send player sts when required --- src/invidious/helpers/signatures.cr | 9 +++++++++ src/invidious/yt_backend/youtube_api.cr | 24 ++++++++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index d9aab31c..b58af73f 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -34,4 +34,13 @@ struct Invidious::DecryptFunction LOGGER.trace(ex.inspect_with_backtrace) return nil end + + def get_sts : UInt64? + self.check_update + return SigHelper::Client.get_sts + rescue ex + LOGGER.debug(ex.message || "Signature: Unknown error") + LOGGER.trace(ex.inspect_with_backtrace) + return nil + end end diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index c8b037c8..f4ee35e5 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -2,6 +2,8 @@ # This file contains youtube API wrappers # +private STS_FETCHER = IV::DecryptFunction.new + module YoutubeAPI extend self @@ -272,7 +274,7 @@ module YoutubeAPI # Return, as a Hash, the "context" data required to request the # youtube API endpoints. # - private def make_context(client_config : ClientConfig | Nil) : Hash + private def make_context(client_config : ClientConfig | Nil, video_id = "dQw4w9WgXcQ") : Hash # Use the default client config if nil is passed client_config ||= DEFAULT_CLIENT_CONFIG @@ -292,7 +294,7 @@ module YoutubeAPI if client_config.screen == "EMBED" client_context["thirdParty"] = { - "embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ", + "embedUrl" => "https://www.youtube.com/embed/#{video_id}", } of String => String | Int64 end @@ -453,19 +455,29 @@ module YoutubeAPI params : String, client_config : ClientConfig | Nil = nil ) + # Playback context, separate because it can be different between clients + playback_ctx = { + "html5Preference" => "HTML5_PREF_WANTS", + "referer" => "https://www.youtube.com/watch?v=#{video_id}", + } of String => String | Int64 + + if {"WEB", "TVHTML5"}.any? { |s| client_config.name.starts_with? s } + if sts = STS_FETCHER.get_sts + playback_ctx["signatureTimestamp"] = sts.to_i64 + end + end + # JSON Request data, required by the API data = { "contentCheckOk" => true, "videoId" => video_id, - "context" => self.make_context(client_config), + "context" => self.make_context(client_config, video_id), "racyCheckOk" => true, "user" => { "lockedSafetyMode" => false, }, "playbackContext" => { - "contentPlaybackContext" => { - "html5Preference": "HTML5_PREF_WANTS", - }, + "contentPlaybackContext" => playback_ctx, }, } -- cgit v1.2.3 From 61d75050e46e5318a1271c2eade29469c8c9e8a5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 4 Jul 2024 15:47:19 +0000 Subject: SigHelper: Use 'URI.parse' instead of 'URI.new' Co-authored-by: Brahim Hadriche --- src/invidious/helpers/sig_helper.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr index b8b985d5..09079850 100644 --- a/src/invidious/helpers/sig_helper.cr +++ b/src/invidious/helpers/sig_helper.cr @@ -274,10 +274,10 @@ class Invidious::SigHelper when .starts_with?('/') @socket = UNIXSocket.new(host_or_path) when .starts_with?("tcp://") - uri = URI.new(host_or_path) + uri = URI.parse(host_or_path) @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) else - uri = URI.new("tcp://#{host_or_path}") + uri = URI.parse("tcp://#{host_or_path}") @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) end -- cgit v1.2.3 From 6506b8dbfce93f9761999b8d91b182350b64b0ff Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 25 Jul 2024 20:08:26 -0700 Subject: Ameba: Fix Naming/PredicateName --- src/invidious/helpers/i18next.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/i18next.cr b/src/invidious/helpers/i18next.cr index c82a1f08..684e6d14 100644 --- a/src/invidious/helpers/i18next.cr +++ b/src/invidious/helpers/i18next.cr @@ -188,7 +188,7 @@ module I18next::Plurals # Emulate the `rule.numbers.size == 2 && rule.numbers[0] == 1` check # from original i18next code - private def is_simple_plural(form : PluralForms) : Bool + private def simple_plural?(form : PluralForms) : Bool case form when .single_gt_one? then return true when .single_not_one? then return true @@ -210,7 +210,7 @@ module I18next::Plurals idx = SuffixIndex.get_index(plural_form, count) # Simple plurals are handled differently in all versions (but v4) - if @simplify_plural_suffix && is_simple_plural(plural_form) + if @simplify_plural_suffix && simple_plural?(plural_form) return (idx == 1) ? "_plural" : "" end -- cgit v1.2.3 From e098c27a4564f936443f298cb59ea63a49b0c118 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 28 Jul 2024 16:44:30 -0700 Subject: Remove unused methods in `Invidious::LogHandler` --- src/invidious/helpers/logger.cr | 13 ------------- 1 file changed, 13 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr index e2e50905..b443073e 100644 --- a/src/invidious/helpers/logger.cr +++ b/src/invidious/helpers/logger.cr @@ -34,24 +34,11 @@ class Invidious::LogHandler < Kemal::BaseLogHandler context end - def puts(message : String) - @io << message << '\n' - @io.flush - end - def write(message : String) @io << message @io.flush end - def set_log_level(level : String) - @level = LogLevel.parse(level) - end - - def set_log_level(level : LogLevel) - @level = level - end - {% for level in %w(trace debug info warn error fatal) %} def {{level.id}}(message : String) if LogLevel::{{level.id.capitalize}} >= @level -- cgit v1.2.3 From 3b7e45b7bc5798e05d49658428b49536d20e745c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 31 Jul 2024 12:17:47 +0200 Subject: SigHelper: Small fixes + suggestions from code review --- src/invidious/helpers/sig_helper.cr | 23 +++++++++-------------- src/invidious/helpers/signatures.cr | 2 +- src/invidious/videos.cr | 2 +- 3 files changed, 11 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr index 09079850..108587ce 100644 --- a/src/invidious/helpers/sig_helper.cr +++ b/src/invidious/helpers/sig_helper.cr @@ -9,7 +9,7 @@ require "socket/unix_socket" private alias NetworkEndian = IO::ByteFormat::NetworkEndian -class Invidious::SigHelper +module Invidious::SigHelper enum UpdateStatus Updated UpdateNotRequired @@ -98,7 +98,7 @@ class Invidious::SigHelper def decrypt_n_param(n : String) : String? request = Request.new(Opcode::DECRYPT_N_SIGNATURE, StringPayload.new(n)) - n_dec = send_request(request) do |bytes| + n_dec = self.send_request(request) do |bytes| StringPayload.from_bytes(bytes).string end @@ -110,7 +110,7 @@ class Invidious::SigHelper def decrypt_sig(sig : String) : String? request = Request.new(Opcode::DECRYPT_SIGNATURE, StringPayload.new(sig)) - sig_dec = send_request(request) do |bytes| + sig_dec = self.send_request(request) do |bytes| StringPayload.from_bytes(bytes).string end @@ -118,10 +118,10 @@ class Invidious::SigHelper end # Return the signature timestamp from the server's current player - def get_sts : UInt64? + def get_signature_timestamp : UInt64? request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil) - return send_request(request) do |bytes| + return self.send_request(request) do |bytes| IO::ByteFormat::NetworkEndian.decode(UInt64, bytes) end end @@ -130,12 +130,12 @@ class Invidious::SigHelper def get_player : UInt32? request = Request.new(Opcode::GET_PLAYER_STATUS, nil) - send_request(request) do |bytes| + return self.send_request(request) do |bytes| has_player = (bytes[0] == 0xFF) player_version = IO::ByteFormat::NetworkEndian.decode(UInt32, bytes[1..4]) + has_player ? player_version : nil end - return has_player ? player_version : nil end private def send_request(request : Request, &) @@ -280,8 +280,7 @@ class Invidious::SigHelper uri = URI.parse("tcp://#{host_or_path}") @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) end - - LOGGER.debug("SigHelper: Listening on '#{host_or_path}'") + LOGGER.info("SigHelper: Using helper at '#{host_or_path}'") {% if flag?(:advanced_debug) %} @io = IO::Hexdump.new(@socket, output: STDERR, read: true, write: true) @@ -296,11 +295,7 @@ class Invidious::SigHelper end def close : Nil - if @socket.closed? - raise Exception.new("SigHelper: Can't close socket, it's already closed") - else - @socket.close - end + @socket.close if !@socket.closed? end def flush(*args, **options) diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index b58af73f..8fbfaac0 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -37,7 +37,7 @@ struct Invidious::DecryptFunction def get_sts : UInt64? self.check_update - return SigHelper::Client.get_sts + return SigHelper::Client.get_signature_timestamp rescue ex LOGGER.debug(ex.message || "Signature: Unknown error") LOGGER.trace(ex.inspect_with_backtrace) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 4e705556..ed172878 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -101,7 +101,7 @@ struct Video # Methods for parsing streaming data def convert_url(fmt) - if cfr = fmt["signatureCipher"]?.try { |h| HTTP::Params.parse(h.as_s) } + if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } sp = cfr["sp"] url = URI.parse(cfr["url"]) params = url.query_params -- cgit v1.2.3 From ec1bb5db87a40d74203a09ca401d0f70d0ad962d Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 1 Aug 2024 23:28:30 +0200 Subject: SigHelper: Add support for PLAYER_UPDATE_TIMESTAMP opcode --- config/config.example.yml | 15 ++++++++++++++- src/invidious/helpers/sig_helper.cr | 9 +++++++++ src/invidious/helpers/signatures.cr | 17 +++++++++++++---- 3 files changed, 36 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 142fdfb7..2f5228a6 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,6 +1,6 @@ ######################################### # -# Database configuration +# Database and other external servers # ######################################### @@ -41,6 +41,19 @@ db: #check_tables: false +## +## Path to an external signature resolver, used to emulate +## the Youtube client's Javascript. If no such server is +## available, some videos will not be playable. +## +## When this setting is commented out, no external +## resolver will be used. +## +## Accepted values: a path to a UNIX socket or ":" +## Default: +## +#signature_server: + ######################################### # diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr index 108587ce..2239858b 100644 --- a/src/invidious/helpers/sig_helper.cr +++ b/src/invidious/helpers/sig_helper.cr @@ -61,6 +61,7 @@ module Invidious::SigHelper DECRYPT_SIGNATURE = 2 GET_SIGNATURE_TIMESTAMP = 3 GET_PLAYER_STATUS = 4 + PLAYER_UPDATE_TIMESTAMP = 5 end private record Request, @@ -135,7 +136,15 @@ module Invidious::SigHelper player_version = IO::ByteFormat::NetworkEndian.decode(UInt32, bytes[1..4]) has_player ? player_version : nil end + end + + # Return when the player was last updated + def get_player_timestamp : UInt64? + request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil) + return self.send_request(request) do |bytes| + IO::ByteFormat::NetworkEndian.decode(UInt64, bytes) + end end private def send_request(request : Request, &) diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index 8fbfaac0..cf170668 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -2,18 +2,27 @@ require "http/params" require "./sig_helper" struct Invidious::DecryptFunction - @last_update = Time.monotonic - 42.days + @last_update : Time = Time.utc - 42.days def initialize self.check_update end def check_update - now = Time.monotonic - if (now - @last_update) > 60.seconds + now = Time.utc + + # If we have updated in the last 5 minutes, do nothing + return if (now - @last_update) > 5.minutes + + # Get the time when the player was updated, in the event where + # multiple invidious processes are run in parallel. + player_ts = Invidious::SigHelper::Client.get_player_timestamp + player_time = Time.unix(player_ts || 0) + + if (now - player_time) > 5.minutes LOGGER.debug("Signature: Player might be outdated, updating") Invidious::SigHelper::Client.force_update - @last_update = Time.monotonic + @last_update = Time.utc end end -- cgit v1.2.3 From 7798faf23425f11cee77742629ca589a5f33392b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 7 Aug 2024 23:12:27 +0200 Subject: SigHelper: Make signature server optional and configurable --- src/invidious.cr | 9 +++++++++ src/invidious/config.cr | 4 ++++ src/invidious/helpers/sig_helper.cr | 27 +++++++++++++++------------ src/invidious/helpers/signatures.cr | 16 ++++++++-------- src/invidious/videos.cr | 6 ++---- src/invidious/yt_backend/youtube_api.cr | 4 +--- 6 files changed, 39 insertions(+), 27 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 0c53197d..3804197e 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -153,6 +153,15 @@ Invidious::Database.check_integrity(CONFIG) {% puts "\nDone checking player dependencies, now compiling Invidious...\n" %} {% end %} +# Misc + +DECRYPT_FUNCTION = + if sig_helper_address = CONFIG.signature_server.presence + IV::DecryptFunction.new(sig_helper_address) + else + nil + end + # Start jobs if CONFIG.channel_threads > 0 diff --git a/src/invidious/config.cr b/src/invidious/config.cr index da911e04..29c39bd6 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -118,6 +118,10 @@ class Config # Connect to YouTube over 'ipv6', 'ipv4'. Will sometimes resolve fix issues with rate-limiting (see https://github.com/ytdl-org/youtube-dl/issues/21729) @[YAML::Field(converter: Preferences::FamilyConverter)] property force_resolve : Socket::Family = Socket::Family::UNSPEC + + # External signature solver server socket (either a path to a UNIX domain socket or ":") + property signature_server : String? = nil + # Port to listen for connections (overridden by command line argument) property port : Int32 = 3000 # Host to bind (overridden by command line argument) diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr index 2239858b..13026321 100644 --- a/src/invidious/helpers/sig_helper.cr +++ b/src/invidious/helpers/sig_helper.cr @@ -72,8 +72,12 @@ module Invidious::SigHelper # High-level functions # ---------------------- - module Client - extend self + class Client + @mux : Multiplexor + + def initialize(uri_or_path) + @mux = Multiplexor.new(uri_or_path) + end # Forces the server to re-fetch the YouTube player, and extract the necessary # components from it (nsig function code, sig function code, signature timestamp). @@ -148,7 +152,7 @@ module Invidious::SigHelper end private def send_request(request : Request, &) - channel = Multiplexor::INSTANCE.send(request) + channel = @mux.send(request) slice = channel.receive return yield slice rescue ex @@ -172,10 +176,8 @@ module Invidious::SigHelper @conn : Connection - INSTANCE = new("") - - def initialize(url : String) - @conn = Connection.new(url) + def initialize(uri_or_path) + @conn = Connection.new(uri_or_path) listen end @@ -275,13 +277,14 @@ module Invidious::SigHelper {% end %} def initialize(host_or_path : String) - if host_or_path.empty? - host_or_path = "/tmp/inv_sig_helper.sock" - end - case host_or_path when .starts_with?('/') - @socket = UNIXSocket.new(host_or_path) + # Make sure that the file exists + if File.exists?(host_or_path) + @socket = UNIXSocket.new(host_or_path) + else + raise Exception.new("SigHelper: '#{host_or_path}' no such file") + end when .starts_with?("tcp://") uri = URI.parse(host_or_path) @socket = TCPSocket.new(uri.host.not_nil!, uri.port.not_nil!) diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index cf170668..a2abf327 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -1,10 +1,11 @@ require "http/params" require "./sig_helper" -struct Invidious::DecryptFunction +class Invidious::DecryptFunction @last_update : Time = Time.utc - 42.days - def initialize + def initialize(uri_or_path) + @client = SigHelper::Client.new(uri_or_path) self.check_update end @@ -16,19 +17,18 @@ struct Invidious::DecryptFunction # Get the time when the player was updated, in the event where # multiple invidious processes are run in parallel. - player_ts = Invidious::SigHelper::Client.get_player_timestamp - player_time = Time.unix(player_ts || 0) + player_time = Time.unix(@client.get_player_timestamp || 0) if (now - player_time) > 5.minutes LOGGER.debug("Signature: Player might be outdated, updating") - Invidious::SigHelper::Client.force_update + @client.force_update @last_update = Time.utc end end def decrypt_nsig(n : String) : String? self.check_update - return SigHelper::Client.decrypt_n_param(n) + return @client.decrypt_n_param(n) rescue ex LOGGER.debug(ex.message || "Signature: Unknown error") LOGGER.trace(ex.inspect_with_backtrace) @@ -37,7 +37,7 @@ struct Invidious::DecryptFunction def decrypt_signature(str : String) : String? self.check_update - return SigHelper::Client.decrypt_sig(str) + return @client.decrypt_sig(str) rescue ex LOGGER.debug(ex.message || "Signature: Unknown error") LOGGER.trace(ex.inspect_with_backtrace) @@ -46,7 +46,7 @@ struct Invidious::DecryptFunction def get_sts : UInt64? self.check_update - return SigHelper::Client.get_signature_timestamp + return @client.get_signature_timestamp rescue ex LOGGER.debug(ex.message || "Signature: Unknown error") LOGGER.trace(ex.inspect_with_backtrace) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index ed172878..8e1e4aac 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1,5 +1,3 @@ -private DECRYPT_FUNCTION = IV::DecryptFunction.new - enum VideoType Video Livestream @@ -108,14 +106,14 @@ struct Video LOGGER.debug("Videos: Decoding '#{cfr}'") - unsig = DECRYPT_FUNCTION.decrypt_signature(cfr["s"]) + unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"]) params[sp] = unsig if unsig else url = URI.parse(fmt["url"].as_s) params = url.query_params end - n = DECRYPT_FUNCTION.decrypt_nsig(params["n"]) + n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"]) params["n"] = n if n params["host"] = url.host.not_nil! diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index f4ee35e5..09a5e7f4 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -2,8 +2,6 @@ # This file contains youtube API wrappers # -private STS_FETCHER = IV::DecryptFunction.new - module YoutubeAPI extend self @@ -462,7 +460,7 @@ module YoutubeAPI } of String => String | Int64 if {"WEB", "TVHTML5"}.any? { |s| client_config.name.starts_with? s } - if sts = STS_FETCHER.get_sts + if sts = DECRYPT_FUNCTION.try &.get_sts playback_ctx["signatureTimestamp"] = sts.to_i64 end end -- cgit v1.2.3 From cc36a8293359764c8df38605818242c60f41bbec Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 7 Aug 2024 23:23:24 +0200 Subject: SigHelper: Fix some logic errors raised during code review --- src/invidious/helpers/sig_helper.cr | 2 +- src/invidious/helpers/signatures.cr | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr index 13026321..9e72c1c7 100644 --- a/src/invidious/helpers/sig_helper.cr +++ b/src/invidious/helpers/sig_helper.cr @@ -144,7 +144,7 @@ module Invidious::SigHelper # Return when the player was last updated def get_player_timestamp : UInt64? - request = Request.new(Opcode::GET_SIGNATURE_TIMESTAMP, nil) + request = Request.new(Opcode::PLAYER_UPDATE_TIMESTAMP, nil) return self.send_request(request) do |bytes| IO::ByteFormat::NetworkEndian.decode(UInt64, bytes) diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index a2abf327..84a8a86d 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -15,11 +15,11 @@ class Invidious::DecryptFunction # If we have updated in the last 5 minutes, do nothing return if (now - @last_update) > 5.minutes - # Get the time when the player was updated, in the event where - # multiple invidious processes are run in parallel. - player_time = Time.unix(@client.get_player_timestamp || 0) + # Get the amount of time elapsed since when the player was updated, in the + # event where multiple invidious processes are run in parallel. + update_time_elapsed = (@client.get_player_timestamp || 301).seconds - if (now - player_time) > 5.minutes + if update_time_elapsed > 5.minutes LOGGER.debug("Signature: Player might be outdated, updating") @client.force_update @last_update = Time.utc -- cgit v1.2.3 From e6c39f9e3a29b1b701f18875f57114cb30c4b8dc Mon Sep 17 00:00:00 2001 From: Emilien Devos <4016501+unixfox@users.noreply.github.com> Date: Tue, 13 Aug 2024 14:37:35 +0200 Subject: add pot= parameter now required by youtube --- src/invidious/videos.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index cdfca02c..44ed53ee 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -110,7 +110,7 @@ struct Video fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}") end - fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") + fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}&pot=#{CONFIG.po_token}") fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? end @@ -130,7 +130,7 @@ struct Video fmt["url"] = JSON::Any.new("#{fmt["url"]}#{DECRYPT_FUNCTION.decrypt_signature(fmt)}") end - fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}") + fmt["url"] = JSON::Any.new("#{fmt["url"]}&host=#{URI.parse(fmt["url"].as_s).host}&pot=#{CONFIG.po_token}") fmt["url"] = JSON::Any.new("#{fmt["url"]}®ion=#{self.info["region"]}") if self.info["region"]? end -- cgit v1.2.3 From e319c35f097e08590e705378c7e5b479720deabc Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 13 Aug 2024 20:56:09 +0200 Subject: Videos: use intermediary variable when using CONFIG.po_token --- src/invidious/videos.cr | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 0d26b395..6d0cf9ba 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -115,7 +115,10 @@ struct Video n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"]) params["n"] = n if n - params["pot"] = CONFIG.po_token if CONFIG.po_token + + if token = CONFIG.po_token + params["pot"] = token + end params["host"] = url.host.not_nil! if region = self.info["region"]?.try &.as_s -- cgit v1.2.3 From 96ade642faad7052b0b70171a2c0ac4c09819151 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 26 Nov 2023 20:24:04 -0500 Subject: Channel: Render age restricted channels --- src/invidious/channels/about.cr | 166 +++++++++++++++++--------------- src/invidious/playlists.cr | 7 ++ src/invidious/routes/api/v1/channels.cr | 89 ++++++++++++----- src/invidious/routes/channels.cr | 74 ++++++++++---- 4 files changed, 219 insertions(+), 117 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index edaf5c12..b3561fcd 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -15,7 +15,8 @@ record AboutChannel, allowed_regions : Array(String), tabs : Array(String), tags : Array(String), - verified : Bool + verified : Bool, + is_age_gated : Bool def get_about_info(ucid, locale) : AboutChannel begin @@ -45,45 +46,101 @@ def get_about_info(ucid, locale) : AboutChannel end tags = [] of String + tab_names = [] of String + total_views = 0_i64 + joined = Time.unix(0) - if auto_generated - author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s - author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s - author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s + if ageGate = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer") + description_node = nil + author = ageGate["channelTitle"].as_s + ucid = initdata["responseContext"]["serviceTrackingParams"][0]["params"][0]["value"].as_s + author_url = "https://www.youtube.com/channel/" + ucid + author_thumbnail = ageGate["avatar"]["thumbnails"][0]["url"].as_s + banner = nil + is_family_friendly = false + is_age_gated = true + tab_names = ["videos", "shorts", "streams"] + auto_generated = false + else + if auto_generated + author = initdata["header"]["interactiveTabbedHeaderRenderer"]["title"]["simpleText"].as_s + author_url = initdata["microformat"]["microformatDataRenderer"]["urlCanonical"].as_s + author_thumbnail = initdata["header"]["interactiveTabbedHeaderRenderer"]["boxArt"]["thumbnails"][0]["url"].as_s + + # Raises a KeyError on failure. + banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? + banner = banners.try &.[-1]?.try &.["url"].as_s? + + description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"] + # some channels have the description in a simpleText + # ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/ + description_node = description_base_node.dig?("simpleText") || description_base_node + + tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges") + .try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String + else + author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s + author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s + author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s + author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges")) - # Raises a KeyError on failure. - banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banner = banners.try &.[-1]?.try &.["url"].as_s? + ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s - description_base_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"] - # some channels have the description in a simpleText - # ex: https://www.youtube.com/channel/UCQvWX73GQygcwXOTSf_VDVg/ - description_node = description_base_node.dig?("simpleText") || description_base_node + # Raises a KeyError on failure. + banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? + banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources") + banner = banners.try &.[-1]?.try &.["url"].as_s? - tags = initdata.dig?("header", "interactiveTabbedHeaderRenderer", "badges") - .try &.as_a.map(&.["metadataBadgeRenderer"]["label"].as_s) || [] of String - else - author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s - author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s - author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s - author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges")) + # if banner.includes? "channels/c4/default_banner" + # banner = nil + # end - ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s + description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]? + tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String + end - # Raises a KeyError on failure. - banners = initdata["header"]["c4TabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? - banners ||= initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "banner", "imageBannerViewModel", "image", "sources") - banner = banners.try &.[-1]?.try &.["url"].as_s? + is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool + if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]? + # Get the name of the tabs available on this channel + tab_names = tabs_json.as_a.compact_map do |entry| + name = entry.dig?("tabRenderer", "title").try &.as_s.downcase - # if banner.includes? "channels/c4/default_banner" - # banner = nil - # end + # This is a small fix to not add extra code on the HTML side + # I.e, the URL for the "live" tab is .../streams, so use "streams" + # everywhere for the sake of simplicity + (name == "live") ? "streams" : name + end - description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]? - tags = initdata.dig?("microformat", "microformatDataRenderer", "tags").try &.as_a.map(&.as_s) || [] of String - end + # Get the currently active tab ("About") + about_tab = extract_selected_tab(tabs_json) + + # Try to find the about metadata section + channel_about_meta = about_tab.dig?( + "content", + "sectionListRenderer", "contents", 0, + "itemSectionRenderer", "contents", 0, + "channelAboutFullMetadataRenderer" + ) - is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool + if !channel_about_meta.nil? + total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 + + # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. + joined = extract_text(channel_about_meta["joinedDateText"]?) + .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) + + # Normal Auto-generated channels + # https://support.google.com/youtube/answer/2579942 + # For auto-generated channels, channel_about_meta only has + # ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] + auto_generated = ( + (channel_about_meta["primaryLinks"]?.try &.size) == 1 && \ + extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" || + channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube" + ) + end + end + end allowed_regions = initdata .dig?("microformat", "microformatDataRenderer", "availableCountries") @@ -102,52 +159,6 @@ def get_about_info(ucid, locale) : AboutChannel end end - total_views = 0_i64 - joined = Time.unix(0) - - tab_names = [] of String - - if tabs_json = initdata["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]? - # Get the name of the tabs available on this channel - tab_names = tabs_json.as_a.compact_map do |entry| - name = entry.dig?("tabRenderer", "title").try &.as_s.downcase - - # This is a small fix to not add extra code on the HTML side - # I.e, the URL for the "live" tab is .../streams, so use "streams" - # everywhere for the sake of simplicity - (name == "live") ? "streams" : name - end - - # Get the currently active tab ("About") - about_tab = extract_selected_tab(tabs_json) - - # Try to find the about metadata section - channel_about_meta = about_tab.dig?( - "content", - "sectionListRenderer", "contents", 0, - "itemSectionRenderer", "contents", 0, - "channelAboutFullMetadataRenderer" - ) - - if !channel_about_meta.nil? - total_views = channel_about_meta.dig?("viewCountText", "simpleText").try &.as_s.gsub(/\D/, "").to_i64? || 0_i64 - - # The joined text is split to several sub strings. The reduce joins those strings before parsing the date. - joined = extract_text(channel_about_meta["joinedDateText"]?) - .try { |text| Time.parse(text, "Joined %b %-d, %Y", Time::Location.local) } || Time.unix(0) - - # Normal Auto-generated channels - # https://support.google.com/youtube/answer/2579942 - # For auto-generated channels, channel_about_meta only has - # ["description"]["simpleText"] and ["primaryLinks"][0]["title"]["simpleText"] - auto_generated = ( - (channel_about_meta["primaryLinks"]?.try &.size) == 1 && \ - extract_text(channel_about_meta.dig?("primaryLinks", 0, "title")) == "Auto-generated by YouTube" || - channel_about_meta.dig?("links", 0, "channelExternalLinkViewModel", "title", "content").try &.as_s == "Auto-generated by YouTube" - ) - end - end - sub_count = 0 if (metadata_rows = initdata.dig?("header", "pageHeaderRenderer", "content", "pageHeaderViewModel", "metadata", "contentMetadataViewModel", "metadataRows").try &.as_a) @@ -177,6 +188,7 @@ def get_about_info(ucid, locale) : AboutChannel tabs: tab_names, tags: tags, verified: author_verified || false, + is_age_gated: is_age_gated || false, ) end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index a227f794..3e6eef95 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -46,8 +46,14 @@ struct PlaylistVideo XML.build { |xml| to_xml(xml) } end + def to_json(locale : String?, json : JSON::Builder) + to_json(json) + end + def to_json(json : JSON::Builder, index : Int32? = nil) json.object do + json.field "type", "video" + json.field "title", self.title json.field "videoId", self.id @@ -67,6 +73,7 @@ struct PlaylistVideo end json.field "lengthSeconds", self.length_seconds + json.field "liveNow", self.live_now end end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 43a5c35b..2da76134 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -27,10 +27,21 @@ module Invidious::Routes::API::V1::Channels # Retrieve "sort by" setting from URL parameters sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" - begin - videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by) - rescue ex - return error_json(500, ex) + if channel.is_age_gated + begin + playlist = get_playlist(channel.ucid.sub("UC", "UULF")) + videos = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + videos = [] of PlaylistVideo + end + next_continuation = nil + else + begin + videos, _ = Channel::Tabs.get_videos(channel, sort_by: sort_by) + rescue ex + return error_json(500, ex) + end end JSON.build do |json| @@ -84,6 +95,7 @@ module Invidious::Routes::API::V1::Channels json.field "joined", channel.joined.to_unix json.field "autoGenerated", channel.auto_generated + json.field "ageGated", channel.is_age_gated json.field "isFamilyFriendly", channel.is_family_friendly json.field "description", html_to_content(channel.description_html) json.field "descriptionHtml", channel.description_html @@ -142,12 +154,23 @@ module Invidious::Routes::API::V1::Channels sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" continuation = env.params.query["continuation"]? - begin - videos, next_continuation = Channel::Tabs.get_60_videos( - channel, continuation: continuation, sort_by: sort_by - ) - rescue ex - return error_json(500, ex) + if channel.is_age_gated + begin + playlist = get_playlist(channel.ucid.sub("UC", "UULF")) + videos = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + videos = [] of PlaylistVideo + end + next_continuation = nil + else + begin + videos, next_continuation = Channel::Tabs.get_60_videos( + channel, continuation: continuation, sort_by: sort_by + ) + rescue ex + return error_json(500, ex) + end end return JSON.build do |json| @@ -176,12 +199,23 @@ module Invidious::Routes::API::V1::Channels # Retrieve continuation from URL parameters continuation = env.params.query["continuation"]? - begin - videos, next_continuation = Channel::Tabs.get_shorts( - channel, continuation: continuation - ) - rescue ex - return error_json(500, ex) + if channel.is_age_gated + begin + playlist = get_playlist(channel.ucid.sub("UC", "UUSH")) + videos = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + videos = [] of PlaylistVideo + end + next_continuation = nil + else + begin + videos, next_continuation = Channel::Tabs.get_shorts( + channel, continuation: continuation + ) + rescue ex + return error_json(500, ex) + end end return JSON.build do |json| @@ -211,12 +245,23 @@ module Invidious::Routes::API::V1::Channels sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" continuation = env.params.query["continuation"]? - begin - videos, next_continuation = Channel::Tabs.get_60_livestreams( - channel, continuation: continuation, sort_by: sort_by - ) - rescue ex - return error_json(500, ex) + if channel.is_age_gated + begin + playlist = get_playlist(channel.ucid.sub("UC", "UULV")) + videos = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + videos = [] of PlaylistVideo + end + next_continuation = nil + else + begin + videos, next_continuation = Channel::Tabs.get_60_livestreams( + channel, continuation: continuation, sort_by: sort_by + ) + rescue ex + return error_json(500, ex) + end end return JSON.build do |json| diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 360af2cd..952098e0 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -36,12 +36,24 @@ module Invidious::Routes::Channels items = items.select(SearchPlaylist) items.each(&.author = "") else - sort_options = {"newest", "oldest", "popular"} - # Fetch items and continuation token - items, next_continuation = Channel::Tabs.get_videos( - channel, continuation: continuation, sort_by: (sort_by || "newest") - ) + if channel.is_age_gated + sort_by = "" + sort_options = [] of String + begin + playlist = get_playlist(channel.ucid.sub("UC", "UULF")) + items = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + items = [] of PlaylistVideo + end + next_continuation = nil + else + sort_options = {"newest", "oldest", "popular"} + items, next_continuation = Channel::Tabs.get_videos( + channel, continuation: continuation, sort_by: (sort_by || "newest") + ) + end end selected_tab = Frontend::ChannelPage::TabsAvailable::Videos @@ -58,14 +70,27 @@ module Invidious::Routes::Channels return env.redirect "/channel/#{channel.ucid}" end - # TODO: support sort option for shorts - sort_by = "" - sort_options = [] of String + if channel.is_age_gated + sort_by = "" + sort_options = [] of String + begin + playlist = get_playlist(channel.ucid.sub("UC", "UUSH")) + items = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + items = [] of PlaylistVideo + end + next_continuation = nil + else + # TODO: support sort option for shorts + sort_by = "" + sort_options = [] of String - # Fetch items and continuation token - items, next_continuation = Channel::Tabs.get_shorts( - channel, continuation: continuation - ) + # Fetch items and continuation token + items, next_continuation = Channel::Tabs.get_shorts( + channel, continuation: continuation + ) + end selected_tab = Frontend::ChannelPage::TabsAvailable::Shorts templated "channel" @@ -81,13 +106,26 @@ module Invidious::Routes::Channels return env.redirect "/channel/#{channel.ucid}" end - sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" - sort_options = {"newest", "oldest", "popular"} + if channel.is_age_gated + sort_by = "" + sort_options = [] of String + begin + playlist = get_playlist(channel.ucid.sub("UC", "UULV")) + items = get_playlist_videos(playlist, offset: 0) + rescue ex : InfoException + # playlist doesnt exist. + items = [] of PlaylistVideo + end + next_continuation = nil + else + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + sort_options = {"newest", "oldest", "popular"} - # Fetch items and continuation token - items, next_continuation = Channel::Tabs.get_60_livestreams( - channel, continuation: continuation, sort_by: sort_by - ) + # Fetch items and continuation token + items, next_continuation = Channel::Tabs.get_60_livestreams( + channel, continuation: continuation, sort_by: sort_by + ) + end selected_tab = Frontend::ChannelPage::TabsAvailable::Streams templated "channel" -- cgit v1.2.3 From e31053e812517d8d097368ae8863404a4a563731 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sun, 19 May 2024 10:46:05 -0400 Subject: Use dig to get properties Co-authored-by: Samantaz Fox --- src/invidious/channels/about.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index b3561fcd..1380044a 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -53,9 +53,9 @@ def get_about_info(ucid, locale) : AboutChannel if ageGate = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer") description_node = nil author = ageGate["channelTitle"].as_s - ucid = initdata["responseContext"]["serviceTrackingParams"][0]["params"][0]["value"].as_s - author_url = "https://www.youtube.com/channel/" + ucid - author_thumbnail = ageGate["avatar"]["thumbnails"][0]["url"].as_s + ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s + author_url = "https://www.youtube.com/channel/#{ucid}" + author_thumbnail = ageGate.dig("avatar", "thumbnails", 0, "url").as_s banner = nil is_family_friendly = false is_age_gated = true -- cgit v1.2.3 From 466bfbb30637b625ceda1e1073dbc190e51c8dc9 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 14 Aug 2024 21:43:37 +0200 Subject: SigHelper: Fix inverted time comparison in 'check_update' --- src/invidious/helpers/signatures.cr | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/signatures.cr b/src/invidious/helpers/signatures.cr index 84a8a86d..82a28fc0 100644 --- a/src/invidious/helpers/signatures.cr +++ b/src/invidious/helpers/signatures.cr @@ -10,10 +10,8 @@ class Invidious::DecryptFunction end def check_update - now = Time.utc - # If we have updated in the last 5 minutes, do nothing - return if (now - @last_update) > 5.minutes + return if (Time.utc - @last_update) < 5.minutes # Get the amount of time elapsed since when the player was updated, in the # event where multiple invidious processes are run in parallel. -- cgit v1.2.3 From acbb62586611ec8fd25df9b56f2042a830933155 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 15 Aug 2024 12:54:43 +0200 Subject: YtAPI: Update clients to latest version --- src/invidious/yt_backend/youtube_api.cr | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 6d585bf2..d66bf7aa 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -6,10 +6,10 @@ module YoutubeAPI extend self # For Android versions, see https://en.wikipedia.org/wiki/Android_version_history - private ANDROID_APP_VERSION = "19.14.42" - private ANDROID_USER_AGENT = "com.google.android.youtube/19.14.42 (Linux; U; Android 12; US) gzip" - private ANDROID_SDK_VERSION = 31_i64 + private ANDROID_APP_VERSION = "19.32.34" private ANDROID_VERSION = "12" + private ANDROID_USER_AGENT = "com.google.android.youtube/#{ANDROID_APP_VERSION} (Linux; U; Android #{ANDROID_VERSION}; US) gzip" + private ANDROID_SDK_VERSION = 31_i64 private ANDROID_TS_APP_VERSION = "1.9" private ANDROID_TS_USER_AGENT = "com.google.android.youtube/1.9 (Linux; U; Android 12; US) gzip" @@ -17,9 +17,9 @@ module YoutubeAPI # For Apple device names, see https://gist.github.com/adamawolf/3048717 # For iOS versions, see https://en.wikipedia.org/wiki/IOS_version_history#Releases, # then go to the dedicated article of the major version you want. - private IOS_APP_VERSION = "19.16.3" - private IOS_USER_AGENT = "com.google.ios.youtube/19.16.3 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)" - private IOS_VERSION = "17.4.0.21E219" # Major.Minor.Patch.Build + private IOS_APP_VERSION = "19.32.8" + private IOS_USER_AGENT = "com.google.ios.youtube/#{IOS_APP_VERSION} (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)" + private IOS_VERSION = "17.6.1.21G93" # Major.Minor.Patch.Build private WINDOWS_VERSION = "10.0" @@ -48,7 +48,7 @@ module YoutubeAPI ClientType::Web => { name: "WEB", name_proto: "1", - version: "2.20240304.00.00", + version: "2.20240814.00.00", screen: "WATCH_FULL_SCREEN", os_name: "Windows", os_version: WINDOWS_VERSION, @@ -57,7 +57,7 @@ module YoutubeAPI ClientType::WebEmbeddedPlayer => { name: "WEB_EMBEDDED_PLAYER", name_proto: "56", - version: "1.20240303.00.00", + version: "1.20240812.01.00", screen: "EMBED", os_name: "Windows", os_version: WINDOWS_VERSION, @@ -66,7 +66,7 @@ module YoutubeAPI ClientType::WebMobile => { name: "MWEB", name_proto: "2", - version: "2.20240304.08.00", + version: "2.20240813.02.00", os_name: "Android", os_version: ANDROID_VERSION, platform: "MOBILE", @@ -74,7 +74,7 @@ module YoutubeAPI ClientType::WebScreenEmbed => { name: "WEB", name_proto: "1", - version: "2.20240304.00.00", + version: "2.20240814.00.00", screen: "EMBED", os_name: "Windows", os_version: WINDOWS_VERSION, @@ -147,8 +147,8 @@ module YoutubeAPI ClientType::IOSMusic => { name: "IOS_MUSIC", name_proto: "26", - version: "6.42", - user_agent: "com.google.ios.youtubemusic/6.42 (iPhone14,5; U; CPU iOS 17_4 like Mac OS X;)", + version: "7.14", + user_agent: "com.google.ios.youtubemusic/7.14 (iPhone14,5; U; CPU iOS 17_6 like Mac OS X;)", device_make: "Apple", device_model: "iPhone14,5", os_name: "iPhone", @@ -161,7 +161,7 @@ module YoutubeAPI ClientType::TvHtml5 => { name: "TVHTML5", name_proto: "7", - version: "7.20240304.10.00", + version: "7.20240813.07.00", }, ClientType::TvHtml5ScreenEmbed => { name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", -- cgit v1.2.3 From cc33d3f074c24be8b9afac5ddbc0465a87f0d867 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 15 Aug 2024 18:13:41 +0200 Subject: YtAPI: Also update User-Agent string --- src/invidious/yt_backend/connection_pool.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 0ac785e6..ca612083 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -1,6 +1,6 @@ def add_yt_headers(request) request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal" - request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36" + request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" -- cgit v1.2.3 From 0b28054f8ac4066d5f2966a75a92eb935247d737 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 15 Aug 2024 18:26:17 +0200 Subject: videos: Fix XSS vulnerability in description/comments Patch provided by e-mail, thanks to an anonymous user whose cats are named Yoshi and Yasuo. Comment is mine --- src/invidious/videos/description.cr | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos/description.cr b/src/invidious/videos/description.cr index c7191dec..1371bebb 100644 --- a/src/invidious/videos/description.cr +++ b/src/invidious/videos/description.cr @@ -36,7 +36,13 @@ def parse_description(desc, video_id : String) : String? return "" if content.empty? commands = desc["commandRuns"]?.try &.as_a - return content if commands.nil? + if commands.nil? + # Slightly faster than HTML.escape, as we're only doing one pass on + # the string instead of five for the standard library + return String.build do |str| + copy_string(str, content.each_codepoint, content.size) + end + end # Not everything is stored in UTF-8 on youtube's side. The SMP codepoints # (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are -- cgit v1.2.3 From 6878822c4d621bc2a2ba65c117efc65246e9a1ca Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 8 Oct 2023 12:58:04 +0200 Subject: Storyboards: Move parser to its own file --- src/invidious/videos.cr | 61 ++------------------------------- src/invidious/videos/storyboard.cr | 69 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 59 deletions(-) create mode 100644 src/invidious/videos/storyboard.cr (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 6d0cf9ba..73321909 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -177,65 +177,8 @@ struct Video # Misc. methods def storyboards - storyboards = info.dig?("storyboards", "playerStoryboardSpecRenderer", "spec") - .try &.as_s.split("|") - - if !storyboards - if storyboard = info.dig?("storyboards", "playerLiveStoryboardSpecRenderer", "spec").try &.as_s - return [{ - url: storyboard.split("#")[0], - width: 106, - height: 60, - count: -1, - interval: 5000, - storyboard_width: 3, - storyboard_height: 3, - storyboard_count: -1, - }] - end - end - - items = [] of NamedTuple( - url: String, - width: Int32, - height: Int32, - count: Int32, - interval: Int32, - storyboard_width: Int32, - storyboard_height: Int32, - storyboard_count: Int32) - - return items if !storyboards - - url = URI.parse(storyboards.shift) - params = HTTP::Params.parse(url.query || "") - - storyboards.each_with_index do |sb, i| - width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#") - params["sigh"] = sigh - url.query = params.to_s - - width = width.to_i - height = height.to_i - count = count.to_i - 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"), - width: width, - height: height, - count: count, - interval: interval, - storyboard_width: storyboard_width, - storyboard_height: storyboard_height, - storyboard_count: storyboard_count, - } - end - - items + container = info.dig?("storyboards") || JSON::Any.new("{}") + return IV::Videos::Storyboard.from_yt_json(container) end def paid diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr new file mode 100644 index 00000000..b4302d88 --- /dev/null +++ b/src/invidious/videos/storyboard.cr @@ -0,0 +1,69 @@ +require "uri" +require "http/params" + +module Invidious::Videos + struct Storyboard + # Parse the JSON structure from Youtube + def self.from_yt_json(container : JSON::Any) + storyboards = container.dig?("playerStoryboardSpecRenderer", "spec") + .try &.as_s.split("|") + + if !storyboards + if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s + return [{ + url: storyboard.split("#")[0], + width: 106, + height: 60, + count: -1, + interval: 5000, + storyboard_width: 3, + storyboard_height: 3, + storyboard_count: -1, + }] + end + end + + items = [] of NamedTuple( + url: String, + width: Int32, + height: Int32, + count: Int32, + interval: Int32, + storyboard_width: Int32, + storyboard_height: Int32, + storyboard_count: Int32) + + return items if !storyboards + + url = URI.parse(storyboards.shift) + params = HTTP::Params.parse(url.query || "") + + storyboards.each_with_index do |sb, i| + width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#") + params["sigh"] = sigh + url.query = params.to_s + + width = width.to_i + height = height.to_i + count = count.to_i + 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"), + width: width, + height: height, + count: count, + interval: interval, + storyboard_width: storyboard_width, + storyboard_height: storyboard_height, + storyboard_count: storyboard_count, + } + end + + items + end + end +end -- cgit v1.2.3 From 8327862697774cd8076335fe2002875dd8c5a84a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 8 Oct 2023 20:09:38 +0200 Subject: Storyboards: Use replace the NamedTuple by a struct --- src/invidious/jsonify/api_v1/video_json.cr | 18 ++++----- src/invidious/routes/api/v1/videos.cr | 19 +++++----- src/invidious/videos/storyboard.cr | 61 +++++++++++++++++------------- 3 files changed, 53 insertions(+), 45 deletions(-) (limited to 'src') diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 59714828..44a34b18 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -273,15 +273,15 @@ module Invidious::JSONify::APIv1 json.array do storyboards.each do |storyboard| json.object do - json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard[:width]}&height=#{storyboard[:height]}" - json.field "templateUrl", storyboard[:url] - json.field "width", storyboard[:width] - json.field "height", storyboard[:height] - json.field "count", storyboard[:count] - json.field "interval", storyboard[:interval] - json.field "storyboardWidth", storyboard[:storyboard_width] - json.field "storyboardHeight", storyboard[:storyboard_height] - json.field "storyboardCount", storyboard[:storyboard_count] + json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard.width}&height=#{storyboard.height}" + json.field "templateUrl", storyboard.url + json.field "width", storyboard.width + json.field "height", storyboard.height + json.field "count", storyboard.count + json.field "interval", storyboard.interval + json.field "storyboardWidth", storyboard.storyboard_width + json.field "storyboardHeight", storyboard.storyboard_height + json.field "storyboardCount", storyboard.storyboard_count end end end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 42282f44..78f91a2e 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -205,7 +205,7 @@ module Invidious::Routes::API::V1::Videos env.response.content_type = "text/vtt" - storyboard = storyboards.select { |sb| width == "#{sb[:width]}" || height == "#{sb[:height]}" } + storyboard = storyboards.select { |sb| width == "#{sb.width}" || height == "#{sb.height}" } if storyboard.empty? haltf env, 404 @@ -215,21 +215,22 @@ module Invidious::Routes::API::V1::Videos WebVTT.build do |vtt| start_time = 0.milliseconds - end_time = storyboard[:interval].milliseconds + end_time = storyboard.interval.milliseconds - storyboard[:storyboard_count].times do |i| - url = storyboard[:url] + storyboard.storyboard_count.times do |i| + url = storyboard.url authority = /(i\d?).ytimg.com/.match!(url)[1]? + url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") url = "#{HOST_URL}/sb/#{authority}/#{url}" - storyboard[:storyboard_height].times do |j| - storyboard[:storyboard_width].times do |k| - current_cue_url = "#{url}#xywh=#{storyboard[:width] * k},#{storyboard[:height] * j},#{storyboard[:width] - 2},#{storyboard[:height]}" + storyboard.storyboard_height.times do |j| + storyboard.storyboard_width.times do |k| + current_cue_url = "#{url}#xywh=#{storyboard.width * k},#{storyboard.height * j},#{storyboard.width - 2},#{storyboard.height}" vtt.cue(start_time, end_time, current_cue_url) - start_time += storyboard[:interval].milliseconds - end_time += storyboard[:interval].milliseconds + start_time += storyboard.interval.milliseconds + end_time += storyboard.interval.milliseconds end end end diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr index b4302d88..797fba12 100644 --- a/src/invidious/videos/storyboard.cr +++ b/src/invidious/videos/storyboard.cr @@ -3,6 +3,21 @@ require "http/params" module Invidious::Videos struct Storyboard + getter url : String + getter width : Int32 + getter height : Int32 + getter count : Int32 + getter interval : Int32 + getter storyboard_width : Int32 + getter storyboard_height : Int32 + getter storyboard_count : Int32 + + def initialize( + *, @url, @width, @height, @count, @interval, + @storyboard_width, @storyboard_height, @storyboard_count + ) + end + # Parse the JSON structure from Youtube def self.from_yt_json(container : JSON::Any) storyboards = container.dig?("playerStoryboardSpecRenderer", "spec") @@ -10,28 +25,20 @@ module Invidious::Videos if !storyboards if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s - return [{ - url: storyboard.split("#")[0], - width: 106, - height: 60, - count: -1, - interval: 5000, - storyboard_width: 3, + return [Storyboard.new( + url: storyboard.split("#")[0], + width: 106, + height: 60, + count: -1, + interval: 5000, + storyboard_width: 3, storyboard_height: 3, - storyboard_count: -1, - }] + storyboard_count: -1, + )] end end - items = [] of NamedTuple( - url: String, - width: Int32, - height: Int32, - count: Int32, - interval: Int32, - storyboard_width: Int32, - storyboard_height: Int32, - storyboard_count: Int32) + items = [] of Storyboard return items if !storyboards @@ -51,16 +58,16 @@ module Invidious::Videos 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"), - width: width, - height: height, - count: count, - interval: interval, - storyboard_width: storyboard_width, + items << Storyboard.new( + url: url.to_s.sub("$L", i).sub("$N", "M$M"), + width: width, + height: height, + count: count, + interval: interval, + storyboard_width: storyboard_width, storyboard_height: storyboard_height, - storyboard_count: storyboard_count, - } + storyboard_count: storyboard_count + ) end items -- cgit v1.2.3 From da3d58f03c9b1617f96f4caf1e348a35105dd79c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 8 Oct 2023 20:29:41 +0200 Subject: Storyboards: Cleanup and document code --- src/invidious/jsonify/api_v1/video_json.cr | 20 ++--- src/invidious/routes/api/v1/videos.cr | 52 +++++++------ src/invidious/videos/storyboard.cr | 114 +++++++++++++++++++---------- 3 files changed, 113 insertions(+), 73 deletions(-) (limited to 'src') diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 44a34b18..4d12a072 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -271,17 +271,17 @@ module Invidious::JSONify::APIv1 def storyboards(json, id, storyboards) json.array do - storyboards.each do |storyboard| + storyboards.each do |sb| json.object do - json.field "url", "/api/v1/storyboards/#{id}?width=#{storyboard.width}&height=#{storyboard.height}" - json.field "templateUrl", storyboard.url - json.field "width", storyboard.width - json.field "height", storyboard.height - json.field "count", storyboard.count - json.field "interval", storyboard.interval - json.field "storyboardWidth", storyboard.storyboard_width - json.field "storyboardHeight", storyboard.storyboard_height - json.field "storyboardCount", storyboard.storyboard_count + json.field "url", "/api/v1/storyboards/#{id}?width=#{sb.width}&height=#{sb.height}" + json.field "templateUrl", sb.url.to_s + json.field "width", sb.width + json.field "height", sb.height + json.field "count", sb.count + json.field "interval", sb.interval + json.field "storyboardWidth", sb.columns + json.field "storyboardHeight", sb.rows + json.field "storyboardCount", sb.images_count end end end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 78f91a2e..fb083934 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -187,15 +187,14 @@ module Invidious::Routes::API::V1::Videos haltf env, 500 end - storyboards = video.storyboards - width = env.params.query["width"]? - height = env.params.query["height"]? + width = env.params.query["width"]?.try &.to_i + height = env.params.query["height"]?.try &.to_i if !width && !height response = JSON.build do |json| json.object do json.field "storyboards" do - Invidious::JSONify::APIv1.storyboards(json, id, storyboards) + Invidious::JSONify::APIv1.storyboards(json, id, video.storyboards) end end end @@ -205,32 +204,37 @@ module Invidious::Routes::API::V1::Videos env.response.content_type = "text/vtt" - storyboard = storyboards.select { |sb| width == "#{sb.width}" || height == "#{sb.height}" } + # Select a storyboard matching the user's provided width/height + storyboard = video.storyboards.select { |x| x.width == width || x.height == height } + haltf env, 404 if storyboard.empty? - if storyboard.empty? - haltf env, 404 - else - storyboard = storyboard[0] - end + # Alias variable, to make the code below esaier to read + sb = storyboard[0] + + # Some base URL segments that we'll use to craft the final URLs + work_url = sb.proxied_url.dup + template_path = sb.proxied_url.path - WebVTT.build do |vtt| - start_time = 0.milliseconds - end_time = storyboard.interval.milliseconds + # Initialize cue timing variables + time_delta = sb.interval.milliseconds + start_time = 0.milliseconds + end_time = time_delta - 1.milliseconds - storyboard.storyboard_count.times do |i| - url = storyboard.url - authority = /(i\d?).ytimg.com/.match!(url)[1]? + # Build a VTT file for VideoJS-vtt plugin + return WebVTT.build do |vtt| + sb.images_count.times do |i| + # Replace the variable component part of the path + work_url.path = template_path.sub("$M", i) - url = url.gsub("$M", i).gsub(%r(https://i\d?.ytimg.com/sb/), "") - url = "#{HOST_URL}/sb/#{authority}/#{url}" + sb.rows.times do |j| + sb.columns.times do |k| + # The URL fragment represents the offset of the thumbnail inside the storyboard image + work_url.fragment = "xywh=#{sb.width * k},#{sb.height * j},#{sb.width - 2},#{sb.height}" - storyboard.storyboard_height.times do |j| - storyboard.storyboard_width.times do |k| - current_cue_url = "#{url}#xywh=#{storyboard.width * k},#{storyboard.height * j},#{storyboard.width - 2},#{storyboard.height}" - vtt.cue(start_time, end_time, current_cue_url) + vtt.cue(start_time, end_time, work_url.to_s) - start_time += storyboard.interval.milliseconds - end_time += storyboard.interval.milliseconds + start_time += time_delta + end_time += time_delta end end end diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr index 797fba12..f6df187f 100644 --- a/src/invidious/videos/storyboard.cr +++ b/src/invidious/videos/storyboard.cr @@ -3,74 +3,110 @@ require "http/params" module Invidious::Videos struct Storyboard - getter url : String + # Template URL + getter url : URI + getter proxied_url : URI + + # Thumbnail parameters getter width : Int32 getter height : Int32 getter count : Int32 getter interval : Int32 - getter storyboard_width : Int32 - getter storyboard_height : Int32 - getter storyboard_count : Int32 + + # Image (storyboard) parameters + getter rows : Int32 + getter columns : Int32 + getter images_count : Int32 def initialize( *, @url, @width, @height, @count, @interval, - @storyboard_width, @storyboard_height, @storyboard_count + @rows, @columns, @images_count ) + authority = /(i\d?).ytimg.com/.match(@url.host.not_nil!).not_nil![1]? + + @proxied_url = URI.parse(HOST_URL) + @proxied_url.path = "/sb/#{authority}#{@url.path}" + @proxied_url.query = @url.query end # Parse the JSON structure from Youtube - def self.from_yt_json(container : JSON::Any) - storyboards = container.dig?("playerStoryboardSpecRenderer", "spec") - .try &.as_s.split("|") - - if !storyboards - if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s - return [Storyboard.new( - url: storyboard.split("#")[0], - width: 106, - height: 60, - count: -1, - interval: 5000, - storyboard_width: 3, - storyboard_height: 3, - storyboard_count: -1, - )] - end + def self.from_yt_json(container : JSON::Any) : Array(Storyboard) + # Livestream storyboards are a bit different + # TODO: document exactly how + if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s + return [Storyboard.new( + url: URI.parse(storyboard.split("#")[0]), + width: 106, + height: 60, + count: -1, + interval: 5000, + rows: 3, + columns: 3, + images_count: -1 + )] end - items = [] of Storyboard + # Split the storyboard string into chunks + # + # General format (whitespaces added for legibility): + # https://i.ytimg.com/sb//storyboard3_L$L/$N.jpg?sqp= + # | 48 # 27 # 100 # 10 # 10 # 0 # default # rs$ + # | 80 # 45 # 95 # 10 # 10 # 10000 # M$M # rs$ + # | 160 # 90 # 95 # 5 # 5 # 10000 # M$M # rs$ + # + storyboards = container.dig?("playerStoryboardSpecRenderer", "spec") + .try &.as_s.split("|") - return items if !storyboards + return [] of Storyboard if !storyboards + # The base URL is the first chunk url = URI.parse(storyboards.shift) - params = HTTP::Params.parse(url.query || "") + params = url.query_params - storyboards.each_with_index do |sb, i| - width, height, count, storyboard_width, storyboard_height, interval, _, sigh = sb.split("#") - params["sigh"] = sigh - url.query = params.to_s + return storyboards.map_with_index do |sb, i| + # Separate the different storyboard parameters: + # width/height: respective dimensions, in pixels, of a single thumbnail + # count: how many thumbnails are displayed across the full video + # columns/rows: maximum amount of thumbnails that can be stuffed in a + # single image, horizontally and vertically. + # interval: interval between two thumbnails, in milliseconds + # sigh: URL cryptographic signature + width, height, count, columns, rows, interval, _, sigh = sb.split("#") width = width.to_i height = height.to_i count = count.to_i 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 + columns = columns.to_i + rows = rows.to_i + + # Add the signature to the URL + params["sigh"] = sigh + url.query = params.to_s - items << Storyboard.new( - url: url.to_s.sub("$L", i).sub("$N", "M$M"), + # Replace the template parts with what we have + url.path = url.path.sub("$L", i).sub("$N", "M$M") + + # This value represents the maximum amount of thumbnails that can fit + # in a single image. The last image (or the only one for short videos) + # will contain less thumbnails than that. + thumbnails_per_image = columns * rows + + # This value represents the total amount of storyboards required to + # hold all of the thumbnails. It can't be less than 1. + images_count = (count / thumbnails_per_image).ceil.to_i + + Storyboard.new( + url: url, width: width, height: height, count: count, interval: interval, - storyboard_width: storyboard_width, - storyboard_height: storyboard_height, - storyboard_count: storyboard_count + rows: rows, + columns: columns, + images_count: images_count, ) end - - items end end end -- cgit v1.2.3 From 7b50388eafcd458221f3deec03bf5a0829244529 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 8 Oct 2023 20:36:32 +0200 Subject: Storyboards: Fix broken first storyboard --- src/invidious/videos.cr | 2 +- src/invidious/videos/storyboard.cr | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 73321909..28cbb311 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -178,7 +178,7 @@ struct Video def storyboards container = info.dig?("storyboards") || JSON::Any.new("{}") - return IV::Videos::Storyboard.from_yt_json(container) + return IV::Videos::Storyboard.from_yt_json(container, self.length_seconds) end def paid diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr index f6df187f..61aafe37 100644 --- a/src/invidious/videos/storyboard.cr +++ b/src/invidious/videos/storyboard.cr @@ -30,7 +30,7 @@ module Invidious::Videos end # Parse the JSON structure from Youtube - def self.from_yt_json(container : JSON::Any) : Array(Storyboard) + def self.from_yt_json(container : JSON::Any, length_seconds : Int32) : Array(Storyboard) # Livestream storyboards are a bit different # TODO: document exactly how if storyboard = container.dig?("playerLiveStoryboardSpecRenderer", "spec").try &.as_s @@ -70,8 +70,9 @@ module Invidious::Videos # columns/rows: maximum amount of thumbnails that can be stuffed in a # single image, horizontally and vertically. # interval: interval between two thumbnails, in milliseconds + # name: storyboard filename. Usually "M$M" or "default" # sigh: URL cryptographic signature - width, height, count, columns, rows, interval, _, sigh = sb.split("#") + width, height, count, columns, rows, interval, name, sigh = sb.split("#") width = width.to_i height = height.to_i @@ -85,7 +86,7 @@ module Invidious::Videos url.query = params.to_s # Replace the template parts with what we have - url.path = url.path.sub("$L", i).sub("$N", "M$M") + url.path = url.path.sub("$L", i).sub("$N", name) # This value represents the maximum amount of thumbnails that can fit # in a single image. The last image (or the only one for short videos) @@ -96,6 +97,12 @@ module Invidious::Videos # hold all of the thumbnails. It can't be less than 1. images_count = (count / thumbnails_per_image).ceil.to_i + # Compute the interval when needed (in general, that's only required + # for the first "default" storyboard). + if interval == 0 + interval = ((length_seconds / count) * 1_000).to_i + end + Storyboard.new( url: url, width: width, -- cgit v1.2.3 From a335bc0814d3253852ed5b5cf58b75d9f7b6cd70 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 20 Oct 2023 23:37:12 +0200 Subject: Storyboards: Fix some small logic mistakes --- src/invidious/videos/storyboard.cr | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr index 61aafe37..35012663 100644 --- a/src/invidious/videos/storyboard.cr +++ b/src/invidious/videos/storyboard.cr @@ -25,7 +25,7 @@ module Invidious::Videos authority = /(i\d?).ytimg.com/.match(@url.host.not_nil!).not_nil![1]? @proxied_url = URI.parse(HOST_URL) - @proxied_url.path = "/sb/#{authority}#{@url.path}" + @proxied_url.path = "/sb/#{authority}/#{@url.path.lchop("/sb/")}" @proxied_url.query = @url.query end @@ -60,8 +60,7 @@ module Invidious::Videos return [] of Storyboard if !storyboards # The base URL is the first chunk - url = URI.parse(storyboards.shift) - params = url.query_params + base_url = URI.parse(storyboards.shift) return storyboards.map_with_index do |sb, i| # Separate the different storyboard parameters: @@ -81,9 +80,13 @@ module Invidious::Videos columns = columns.to_i rows = rows.to_i + # Copy base URL object, so that we can modify it + url = base_url.dup + # Add the signature to the URL + params = url.query_params params["sigh"] = sigh - url.query = params.to_s + url.query_params = params # Replace the template parts with what we have url.path = url.path.sub("$L", i).sub("$N", name) -- cgit v1.2.3 From 5b05f3bd147c6cf9421587565dea2b11640f1206 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 16 Aug 2024 11:28:35 +0200 Subject: Storyboards: Workarounds for videojs-vtt-thumbnails The workarounds are as follow: * Unescape HTML entities * Always use 0:00:00.000 for cue start/end --- src/invidious/routes/api/v1/videos.cr | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index fb083934..ab03df01 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -1,3 +1,5 @@ +require "html" + module Invidious::Routes::API::V1::Videos def self.videos(env) locale = env.get("preferences").as(Preferences).locale @@ -216,12 +218,14 @@ module Invidious::Routes::API::V1::Videos template_path = sb.proxied_url.path # Initialize cue timing variables + # NOTE: videojs-vtt-thumbnails gets lost when the start and end times are not 0:00:000.000 + # TODO: Use proper end time when videojs-vtt-thumbnails is fixed time_delta = sb.interval.milliseconds start_time = 0.milliseconds - end_time = time_delta - 1.milliseconds + end_time = 0.milliseconds # time_delta - 1.milliseconds # Build a VTT file for VideoJS-vtt plugin - return WebVTT.build do |vtt| + vtt_file = WebVTT.build do |vtt| sb.images_count.times do |i| # Replace the variable component part of the path work_url.path = template_path.sub("$M", i) @@ -233,12 +237,18 @@ module Invidious::Routes::API::V1::Videos vtt.cue(start_time, end_time, work_url.to_s) - start_time += time_delta - end_time += time_delta + # TODO: uncomment these when videojs-vtt-thumbnails is fixed + # start_time += time_delta + # end_time += time_delta end end end end + + # videojs-vtt-thumbnails is not compliant to the VTT specification, it + # doesn't unescape the HTML entities, so we have to do it here: + # TODO: remove this when we migrate to VideoJS 8 + return HTML.unescape(vtt_file) end def self.annotations(env) -- cgit v1.2.3 From b795bdf2a4a50fc899fde9dc7b42b845a4588bfc Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 16 Aug 2024 12:10:22 +0200 Subject: HTML: Sort playlists alphabetically in watch page drop down --- src/invidious/database/playlists.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious/database/playlists.cr b/src/invidious/database/playlists.cr index c6754a1e..08aa719a 100644 --- a/src/invidious/database/playlists.cr +++ b/src/invidious/database/playlists.cr @@ -140,6 +140,7 @@ module Invidious::Database::Playlists request = <<-SQL SELECT id,title FROM playlists WHERE author = $1 AND id LIKE 'IV%' + ORDER BY title SQL PG_DB.query_all(request, email, as: {String, String}) -- cgit v1.2.3 From 764965c441a789e0be417648716f575067d9201e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 17 Aug 2024 12:20:53 +0200 Subject: Storyboards: Fix lint error --- src/invidious/videos/storyboard.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos/storyboard.cr b/src/invidious/videos/storyboard.cr index 35012663..a72c2f55 100644 --- a/src/invidious/videos/storyboard.cr +++ b/src/invidious/videos/storyboard.cr @@ -22,7 +22,7 @@ module Invidious::Videos *, @url, @width, @height, @count, @interval, @rows, @columns, @images_count ) - authority = /(i\d?).ytimg.com/.match(@url.host.not_nil!).not_nil![1]? + authority = /(i\d?).ytimg.com/.match!(@url.host.not_nil!)[1]? @proxied_url = URI.parse(HOST_URL) @proxied_url.path = "/sb/#{authority}/#{@url.path.lchop("/sb/")}" -- cgit v1.2.3 From eb0f651812d7d01c038f5a052bf30fc8e26b877f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 1 Oct 2023 19:39:53 +0200 Subject: Add a youtube URL sanitizer --- src/invidious/yt_backend/url_sanitizer.cr | 121 ++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 src/invidious/yt_backend/url_sanitizer.cr (limited to 'src') diff --git a/src/invidious/yt_backend/url_sanitizer.cr b/src/invidious/yt_backend/url_sanitizer.cr new file mode 100644 index 00000000..02bf77bf --- /dev/null +++ b/src/invidious/yt_backend/url_sanitizer.cr @@ -0,0 +1,121 @@ +require "uri" + +module UrlSanitizer + extend self + + ALLOWED_QUERY_PARAMS = { + channel: ["u", "user", "lb"], + playlist: ["list"], + search: ["q", "search_query", "sp"], + watch: [ + "v", # Video ID + "list", "index", # Playlist-related + "playlist", # Unnamed playlist (id,id,id,...) (embed-only?) + "t", "time_continue", "start", "end", # Timestamp + "lc", # Highlighted comment (watch page only) + ], + } + + # Returns wether the given string is an ASCII word. This is the same as + # running the following regex in US-ASCII locale: /^[\w-]+$/ + private def ascii_word?(str : String) : Bool + if str.bytesize == str.size + str.each_byte do |byte| + next if 'a'.ord <= byte <= 'z'.ord + next if 'A'.ord <= byte <= 'Z'.ord + next if '0'.ord <= byte <= '9'.ord + next if byte == '-'.ord || byte == '_'.ord + + return false + end + + return true + else + return false + end + end + + # Return which kind of parameters are allowed based on the + # first path component (breadcrumb 0). + private def determine_allowed(path_root : String) + case path_root + when "watch", "w", "v", "embed", "e", "shorts", "clip" + return :watch + when .starts_with?("@"), "c", "channel", "user", "profile", "attribution_link" + return :channel + when "playlist", "mix" + return :playlist + when "results", "search" + return :search + else # hashtag, post, trending, brand URLs, etc.. + return nil + end + end + + # Create a new URI::Param containing only the allowed parameters + private def copy_params(unsafe_params : URI::Params, allowed_type) : URI::Params + new_params = URI::Params.new + + ALLOWED_QUERY_PARAMS[allowed_type].each do |name| + if unsafe_params[name]? + # Only copy the last parameter, in case there is more than one + new_params[name] = unsafe_params.fetch_all(name)[-1] + end + end + + return new_params + end + + # Transform any user-supplied youtube URL into something we can trust + # and use across the code. + def process(str : String) : URI + # Because URI follows RFC3986 specifications, URL without a scheme + # will be parsed as a relative path. So we have to add a scheme ourselves. + str = "https://#{str}" if !str.starts_with?(/https?:\/\//) + + unsafe_uri = URI.parse(str) + new_uri = URI.new(path: "/") + + # Redirect to homepage for bogus URLs + return new_uri if (unsafe_uri.host.nil? || unsafe_uri.path.nil?) + + breadcrumbs = unsafe_uri.path + .split('/', remove_empty: true) + .compact_map do |bc| + # Exclude attempts at path trasversal + next if bc == "." || bc == ".." + + # Non-alnum characters are unlikely in a genuine URL + next if !ascii_word?(bc) + + bc + end + + # If nothing remains, it's either a legit URL to the homepage + # (who does that!?) or because we filtered some junk earlier. + return new_uri if breadcrumbs.empty? + + # Replace the original query parameters with the sanitized ones + case unsafe_uri.host.not_nil! + when .ends_with?("youtube.com") + # Use our sanitized path (not forgetting the leading '/') + new_uri.path = "/#{breadcrumbs.join('/')}" + + # Then determine which params are allowed, and copy them over + if allowed = determine_allowed(breadcrumbs[0]) + new_uri.query_params = copy_params(unsafe_uri.query_params, allowed) + end + when "youtu.be" + # Always redirect to the watch page + new_uri.path = "/watch" + + new_params = copy_params(unsafe_uri.query_params, :watch) + new_params["id"] = breadcrumbs[0] + + new_uri.query_params = new_params + end + + new_uri.host = nil # Safety measure + return new_uri + end +end -- cgit v1.2.3 From 4c0b5c314d68ea45e69de9673f0bf43bedf3acc4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 5 Oct 2023 23:01:44 +0200 Subject: Search: Add support for youtu.be and youtube.com URLs --- src/invidious/routes/search.cr | 6 ++++++ src/invidious/search/query.cr | 27 +++++++++++++++++++++------ 2 files changed, 27 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 5be33533..85aa1c7e 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -51,6 +51,12 @@ module Invidious::Routes::Search else user = env.get? "user" + # An URL was copy/pasted in the search box. + # Redirect the user to the appropriate page. + if query.is_url? + return env.redirect UrlSanitizer.process(query.text).to_s + end + begin items = query.process rescue ex : ChannelSearchException diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr index e38845d9..f87c243e 100644 --- a/src/invidious/search/query.cr +++ b/src/invidious/search/query.cr @@ -48,11 +48,12 @@ module Invidious::Search ) # Get the raw search query string (common to all search types). In # Regular search mode, also look for the `search_query` URL parameter - if @type.regular? - @raw_query = params["q"]? || params["search_query"]? || "" - else - @raw_query = params["q"]? || "" - end + _raw_query = params["q"]? + _raw_query ||= params["search_query"]? if @type.regular? + _raw_query ||= "" + + # Remove surrounding whitespaces. Mostly useful for copy/pasted URLs. + @raw_query = _raw_query.strip # Get the page number (also common to all search types) @page = params["page"]?.try &.to_i? || 1 @@ -85,7 +86,7 @@ module Invidious::Search @filters = Filters.from_iv_params(params) @channel = params["channel"]? || "" - if @filters.default? && @raw_query.includes?(':') + if @filters.default? && @raw_query.index(/\w:\w/) # Parse legacy filters from query @filters, @channel, @query, subs = Filters.from_legacy_filters(@raw_query) else @@ -136,5 +137,19 @@ module Invidious::Search return params end + + # Checks if the query is a standalone URL + def is_url? : Bool + # Only supported in regular search mode + return false if !@type.regular? + + # If filters are present, that's a regular search + return false if !@filters.default? + + # Simple heuristics: domain name + return @raw_query.starts_with?( + /(https?:\/\/)?(www\.)?(m\.)?youtu(\.be|be\.com)\// + ) + end end end -- cgit v1.2.3 From 31a80420ec9f4dbd61a7145044f5e1797d4e0dd0 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 13 Feb 2024 21:46:12 +0100 Subject: Search: Add URL search inhibition logic --- src/invidious/search/query.cr | 13 +++++++++++++ 1 file changed, 13 insertions(+) (limited to 'src') diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr index f87c243e..b3db0f63 100644 --- a/src/invidious/search/query.cr +++ b/src/invidious/search/query.cr @@ -20,6 +20,9 @@ module Invidious::Search property region : String? property channel : String = "" + # Flag that indicates if the smart search features have been disabled. + @inhibit_ssf : Bool = false + # Return true if @raw_query is either `nil` or empty private def empty_raw_query? return @raw_query.empty? @@ -55,6 +58,13 @@ module Invidious::Search # Remove surrounding whitespaces. Mostly useful for copy/pasted URLs. @raw_query = _raw_query.strip + # Check for smart features (ex: URL search) inhibitor (exclamation mark). + # If inhibitor is present, remove it. + if @raw_query.starts_with?('!') + @inhibit_ssf = true + @raw_query = @raw_query[1..] + end + # Get the page number (also common to all search types) @page = params["page"]?.try &.to_i? || 1 @@ -140,6 +150,9 @@ module Invidious::Search # Checks if the query is a standalone URL def is_url? : Bool + # If the smart features have been inhibited, don't go further. + return false if @inhibit_ssf + # Only supported in regular search mode return false if !@type.regular? -- cgit v1.2.3 From 78c5ba93c7f4eecf7aae623079c0c77f78670b67 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 17 Feb 2024 14:27:25 +0100 Subject: Misc: Clean some code in UrlSanitizer --- src/invidious/yt_backend/url_sanitizer.cr | 32 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/url_sanitizer.cr b/src/invidious/yt_backend/url_sanitizer.cr index 02bf77bf..725382ee 100644 --- a/src/invidious/yt_backend/url_sanitizer.cr +++ b/src/invidious/yt_backend/url_sanitizer.cr @@ -16,23 +16,21 @@ module UrlSanitizer ], } - # Returns wether the given string is an ASCII word. This is the same as + # Returns whether the given string is an ASCII word. This is the same as # running the following regex in US-ASCII locale: /^[\w-]+$/ private def ascii_word?(str : String) : Bool - if str.bytesize == str.size - str.each_byte do |byte| - next if 'a'.ord <= byte <= 'z'.ord - next if 'A'.ord <= byte <= 'Z'.ord - next if '0'.ord <= byte <= '9'.ord - next if byte == '-'.ord || byte == '_'.ord - - return false - end + return false if str.bytesize != str.size + + str.each_byte do |byte| + next if 'a'.ord <= byte <= 'z'.ord + next if 'A'.ord <= byte <= 'Z'.ord + next if '0'.ord <= byte <= '9'.ord + next if byte == '-'.ord || byte == '_'.ord - return true - else return false end + + return true end # Return which kind of parameters are allowed based on the @@ -74,12 +72,15 @@ module UrlSanitizer str = "https://#{str}" if !str.starts_with?(/https?:\/\//) unsafe_uri = URI.parse(str) + unsafe_host = unsafe_uri.host + unsafe_path = unsafe_uri.path + new_uri = URI.new(path: "/") # Redirect to homepage for bogus URLs - return new_uri if (unsafe_uri.host.nil? || unsafe_uri.path.nil?) + return new_uri if (unsafe_host.nil? || unsafe_path.nil?) - breadcrumbs = unsafe_uri.path + breadcrumbs = unsafe_path .split('/', remove_empty: true) .compact_map do |bc| # Exclude attempts at path trasversal @@ -96,7 +97,7 @@ module UrlSanitizer return new_uri if breadcrumbs.empty? # Replace the original query parameters with the sanitized ones - case unsafe_uri.host.not_nil! + case unsafe_host when .ends_with?("youtube.com") # Use our sanitized path (not forgetting the leading '/') new_uri.path = "/#{breadcrumbs.join('/')}" @@ -115,7 +116,6 @@ module UrlSanitizer new_uri.query_params = new_params end - new_uri.host = nil # Safety measure return new_uri end end -- cgit v1.2.3 From 85deea5aca4877507bb8850e5e3e168d968328ad Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 27 Apr 2024 23:21:27 +0200 Subject: Search: Change smart search inhibitor to a backslash --- src/invidious/search/query.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr index b3db0f63..a93bb3f9 100644 --- a/src/invidious/search/query.cr +++ b/src/invidious/search/query.cr @@ -58,9 +58,9 @@ module Invidious::Search # Remove surrounding whitespaces. Mostly useful for copy/pasted URLs. @raw_query = _raw_query.strip - # Check for smart features (ex: URL search) inhibitor (exclamation mark). + # Check for smart features (ex: URL search) inhibitor (backslash). # If inhibitor is present, remove it. - if @raw_query.starts_with?('!') + if @raw_query.starts_with?('\\') @inhibit_ssf = true @raw_query = @raw_query[1..] end -- cgit v1.2.3 From c606465708720c953c37032624ff31e5e9d841ab Mon Sep 17 00:00:00 2001 From: Colin Leroy-Mira Date: Mon, 19 Aug 2024 09:34:51 +0200 Subject: Proxify formatStreams URLs too --- src/invidious/jsonify/api_v1/video_json.cr | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 59714828..e4379601 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -162,7 +162,13 @@ module Invidious::JSONify::APIv1 json.array do video.fmt_stream.each do |fmt| json.object do - json.field "url", fmt["url"] + if proxy + json.field "url", Invidious::HttpServer::Utils.proxy_video_url( + fmt["url"].to_s, absolute: true + ) + else + json.field "url", fmt["url"] + end json.field "itag", fmt["itag"].as_i.to_s json.field "type", fmt["mimeType"] json.field "quality", fmt["quality"] -- cgit v1.2.3 From 22b35c453ede48e36db1657c5b8e879f3cc70a56 Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 25 Jul 2024 20:12:17 -0700 Subject: Ameba: Fix Style/WhileTrue --- src/invidious/routes/video_playback.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index ec18f3b8..24693662 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -131,7 +131,7 @@ module Invidious::Routes::VideoPlayback end # TODO: Record bytes written so we can restart after a chunk fails - while true + loop do if !range_end && content_length range_end = content_length end -- cgit v1.2.3 From f66068976e5f077d363769055b7533cd0f85d6d0 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 26 Jul 2024 19:19:31 -0700 Subject: Ameba: Fix Naming/PredicateName --- src/invidious/helpers/serialized_yt_data.cr | 4 ++-- src/invidious/jsonify/api_v1/video_json.cr | 2 +- src/invidious/user/imports.cr | 4 ++-- src/invidious/videos.cr | 20 ++++++++++++++++++-- src/invidious/views/watch.ecr | 2 +- 5 files changed, 24 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 31a3cf44..463d5557 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -90,7 +90,7 @@ struct SearchVideo json.field "lengthSeconds", self.length_seconds json.field "liveNow", self.live_now json.field "premium", self.premium - json.field "isUpcoming", self.is_upcoming + json.field "isUpcoming", self.upcoming? if self.premiere_timestamp json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix @@ -109,7 +109,7 @@ struct SearchVideo to_json(nil, json) end - def is_upcoming + def upcoming? premiere_timestamp ? true : false end end diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 59714828..2d41ed3b 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -63,7 +63,7 @@ module Invidious::JSONify::APIv1 json.field "isListed", video.is_listed json.field "liveNow", video.live_now json.field "isPostLiveDvr", video.post_live_dvr - json.field "isUpcoming", video.is_upcoming + json.field "isUpcoming", video.upcoming? if video.premiere_timestamp json.field "premiereTimestamp", video.premiere_timestamp.try &.to_unix diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index a70434ca..2b5f88f4 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -161,7 +161,7 @@ struct Invidious::User # Youtube # ------------------- - private def is_opml?(mimetype : String, extension : String) + private def opml?(mimetype : String, extension : String) opml_mimetypes = [ "application/xml", "text/xml", @@ -179,7 +179,7 @@ struct Invidious::User def from_youtube(user : User, body : String, filename : String, type : String) : Bool extension = filename.split(".").last - if is_opml?(type, extension) + if opml?(type, extension) subscriptions = XML.parse(body) user.subscriptions += subscriptions.xpath_nodes(%q(//outline[@type="rss"])).map do |channel| channel["xmlUrl"].match!(/UC[a-zA-Z0-9_-]{22}/)[0] diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 6d0cf9ba..65b07fe8 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -280,7 +280,7 @@ struct Video info["genreUcid"].try &.as_s? ? "/channel/#{info["genreUcid"]}" : nil end - def is_vr : Bool? + def vr? : Bool? return {"EQUIRECTANGULAR", "MESH"}.includes? self.projection_type end @@ -361,6 +361,21 @@ struct Video {% if flag?(:debug_macros) %} {{debug}} {% end %} end + # Macro to generate ? and = accessor methods for attributes in `info` + private macro predicate_bool(method_name, name) + # Return {{name.stringify}} from `info` + def {{method_name.id.underscore}}? : Bool + return info[{{name.stringify}}]?.try &.as_bool || false + end + + # Update {{name.stringify}} into `info` + def {{method_name.id.underscore}}=(value : Bool) + info[{{name.stringify}}] = JSON::Any.new(value) + end + + {% if flag?(:debug_macros) %} {{debug}} {% end %} + end + # Method definitions, using the macros above getset_string author @@ -382,11 +397,12 @@ struct Video getset_i64 likes getset_i64 views + # TODO: Make predicate_bool the default as to adhere to Crystal conventions getset_bool allowRatings getset_bool authorVerified getset_bool isFamilyFriendly getset_bool isListed - getset_bool isUpcoming + predicate_bool upcoming, isUpcoming end def get_video(id, refresh = true, region = nil, force_refresh = false) diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 36679bce..45c58a16 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -62,7 +62,7 @@ we're going to need to do it here in order to allow for translations. "params" => params, "preferences" => preferences, "premiere_timestamp" => video.premiere_timestamp.try &.to_unix, - "vr" => video.is_vr, + "vr" => video.vr?, "projection_type" => video.projection_type, "local_disabled" => CONFIG.disabled?("local"), "support_reddit" => true -- cgit v1.2.3 From d1cd7903882b23eedae6ff28441c1adc40b5be7b Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 26 Jul 2024 19:20:06 -0700 Subject: Ameba: Fix Lint/RedundantStringCoercion --- src/invidious/jsonify/api_v1/video_json.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/jsonify/api_v1/video_json.cr b/src/invidious/jsonify/api_v1/video_json.cr index 2d41ed3b..3625b8f1 100644 --- a/src/invidious/jsonify/api_v1/video_json.cr +++ b/src/invidious/jsonify/api_v1/video_json.cr @@ -109,7 +109,7 @@ module Invidious::JSONify::APIv1 # On livestreams, it's not present, so always fall back to the # current unix timestamp (up to mS precision) for compatibility. last_modified = fmt["lastModified"]? - last_modified ||= "#{Time.utc.to_unix_ms.to_s}000" + last_modified ||= "#{Time.utc.to_unix_ms}000" json.field "lmt", last_modified json.field "projectionType", fmt["projectionType"] -- cgit v1.2.3 From ecbea0b67b7b478597e40b530c0df8cd212e4faf Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 26 Jul 2024 19:22:42 -0700 Subject: Ameba: Fix Lint/ShadowingOuterLocalVar --- src/invidious/routes/api/v1/videos.cr | 4 ++-- src/invidious/user/imports.cr | 2 +- src/invidious/videos/transcript.cr | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 42282f44..c49a9b7b 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -116,7 +116,7 @@ module Invidious::Routes::API::V1::Videos else caption_xml = XML.parse(caption_xml) - webvtt = WebVTT.build(settings_field) do |webvtt| + webvtt = WebVTT.build(settings_field) do |builder| caption_nodes = caption_xml.xpath_nodes("//transcript/text") caption_nodes.each_with_index do |node, i| start_time = node["start"].to_f.seconds @@ -136,7 +136,7 @@ module Invidious::Routes::API::V1::Videos text = "#{md["text"]}" end - webvtt.cue(start_time, end_time, text) + builder.cue(start_time, end_time, text) end end end diff --git a/src/invidious/user/imports.cr b/src/invidious/user/imports.cr index 2b5f88f4..533c18d9 100644 --- a/src/invidious/user/imports.cr +++ b/src/invidious/user/imports.cr @@ -115,7 +115,7 @@ struct Invidious::User playlists.each do |item| title = item["title"]?.try &.as_s?.try &.delete("<>") description = item["description"]?.try &.as_s?.try &.delete("\r") - privacy = item["privacy"]?.try &.as_s?.try { |privacy| PlaylistPrivacy.parse? privacy } + privacy = item["privacy"]?.try &.as_s?.try { |raw_pl_privacy_state| PlaylistPrivacy.parse? raw_pl_privacy_state } next if !title next if !description diff --git a/src/invidious/videos/transcript.cr b/src/invidious/videos/transcript.cr index 9cd064c5..4bd9f820 100644 --- a/src/invidious/videos/transcript.cr +++ b/src/invidious/videos/transcript.cr @@ -110,13 +110,13 @@ module Invidious::Videos "Language" => @language_code, } - vtt = WebVTT.build(settings_field) do |vtt| + vtt = WebVTT.build(settings_field) do |builder| @lines.each do |line| # Section headers are excluded from the VTT conversion as to # match the regular captions returned from YouTube as much as possible next if line.is_a? HeadingLine - vtt.cue(line.start_ms, line.end_ms, line.line) + builder.cue(line.start_ms, line.end_ms, line.line) end end -- cgit v1.2.3 From 21ab5dc6680da3df62feed14c00104754f2479a4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 22 Aug 2024 00:29:15 +0200 Subject: Storyboard: Revert cue timing "fix" --- src/invidious/routes/api/v1/videos.cr | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index ab03df01..c077b85e 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -218,11 +218,11 @@ module Invidious::Routes::API::V1::Videos template_path = sb.proxied_url.path # Initialize cue timing variables - # NOTE: videojs-vtt-thumbnails gets lost when the start and end times are not 0:00:000.000 - # TODO: Use proper end time when videojs-vtt-thumbnails is fixed + # NOTE: videojs-vtt-thumbnails gets lost when the cue times don't overlap + # (i.e: if cue[n] end time is 1:06:25.000, cue[n+1] start time should be 1:06:25.000) time_delta = sb.interval.milliseconds start_time = 0.milliseconds - end_time = 0.milliseconds # time_delta - 1.milliseconds + end_time = time_delta # Build a VTT file for VideoJS-vtt plugin vtt_file = WebVTT.build do |vtt| @@ -237,9 +237,8 @@ module Invidious::Routes::API::V1::Videos vtt.cue(start_time, end_time, work_url.to_s) - # TODO: uncomment these when videojs-vtt-thumbnails is fixed - # start_time += time_delta - # end_time += time_delta + start_time += time_delta + end_time += time_delta end end end -- cgit v1.2.3 From b2133c6b2c5b83f40f12679a7fee17963a4d34aa Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 15 Aug 2024 18:10:38 +0200 Subject: Videos: Convert URL before putting result into cache --- src/invidious/videos.cr | 80 +++++++----------------------------------- src/invidious/videos/parser.cr | 45 +++++++++++++++++++++++- 2 files changed, 57 insertions(+), 68 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 6d0cf9ba..8b299641 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -26,12 +26,6 @@ struct Video @[DB::Field(ignore: true)] @captions = [] of Invidious::Videos::Captions::Metadata - @[DB::Field(ignore: true)] - property adaptive_fmts : Array(Hash(String, JSON::Any))? - - @[DB::Field(ignore: true)] - property fmt_stream : Array(Hash(String, JSON::Any))? - @[DB::Field(ignore: true)] property description : String? @@ -98,72 +92,24 @@ struct Video # Methods for parsing streaming data - def convert_url(fmt) - if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } - sp = cfr["sp"] - url = URI.parse(cfr["url"]) - params = url.query_params - - LOGGER.debug("Videos: Decoding '#{cfr}'") - - unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"]) - params[sp] = unsig if unsig + def fmt_stream : Array(Hash(String, JSON::Any)) + if formats = info.dig?("streamingData", "formats") + return formats + .as_a.map(&.as_h) + .sort_by! { |f| f["width"]?.try &.as_i || 0 } else - url = URI.parse(fmt["url"].as_s) - params = url.query_params - end - - n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"]) - params["n"] = n if n - - if token = CONFIG.po_token - params["pot"] = token - end - - params["host"] = url.host.not_nil! - if region = self.info["region"]?.try &.as_s - params["region"] = region - end - - url.query_params = params - LOGGER.trace("Videos: new url is '#{url}'") - - return url.to_s - rescue ex - LOGGER.debug("Videos: Error when parsing video URL") - LOGGER.trace(ex.inspect_with_backtrace) - return "" - end - - def fmt_stream - return @fmt_stream.as(Array(Hash(String, JSON::Any))) if @fmt_stream - - fmt_stream = info.dig?("streamingData", "formats") - .try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) - - fmt_stream.each do |fmt| - fmt["url"] = JSON::Any.new(self.convert_url(fmt)) + return [] of Hash(String, JSON::Any) end - - fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } - @fmt_stream = fmt_stream - return @fmt_stream.as(Array(Hash(String, JSON::Any))) end - def adaptive_fmts - return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) if @adaptive_fmts - - fmt_stream = info.dig("streamingData", "adaptiveFormats") - .try &.as_a.map &.as_h || [] of Hash(String, JSON::Any) - - fmt_stream.each do |fmt| - fmt["url"] = JSON::Any.new(self.convert_url(fmt)) + def adaptive_fmts : Array(Hash(String, JSON::Any)) + if formats = info.dig?("streamingData", "adaptiveFormats") + return formats + .as_a.map(&.as_h) + .sort_by! { |f| f["width"]?.try &.as_i || 0 } + else + return [] of Hash(String, JSON::Any) end - - fmt_stream.sort_by! { |f| f["width"]?.try &.as_i || 0 } - @adaptive_fmts = fmt_stream - - return @adaptive_fmts.as(Array(Hash(String, JSON::Any))) end def video_streams diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 95fa3d79..4683058b 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -132,10 +132,21 @@ def extract_video_info(video_id : String) params.delete("reason") end - {"captions", "playabilityStatus", "playerConfig", "storyboards", "streamingData"}.each do |f| + {"captions", "playabilityStatus", "playerConfig", "storyboards"}.each do |f| params[f] = player_response[f] if player_response[f]? end + # Convert URLs, if those are present + if streaming_data = player_response["streamingData"]? + %w[formats adaptiveFormats].each do |key| + streaming_data.as_h[key]?.try &.as_a.each do |format| + format.as_h["url"] = JSON::Any.new(convert_url(format)) + end + end + + params["streamingData"] = streaming_data + end + # Data structure version, for cache control params["version"] = JSON::Any.new(Video::SCHEMA_VERSION.to_i64) @@ -443,3 +454,35 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any return params end + +private def convert_url(fmt) + if cfr = fmt["signatureCipher"]?.try { |json| HTTP::Params.parse(json.as_s) } + sp = cfr["sp"] + url = URI.parse(cfr["url"]) + params = url.query_params + + LOGGER.debug("convert_url: Decoding '#{cfr}'") + + unsig = DECRYPT_FUNCTION.try &.decrypt_signature(cfr["s"]) + params[sp] = unsig if unsig + else + url = URI.parse(fmt["url"].as_s) + params = url.query_params + end + + n = DECRYPT_FUNCTION.try &.decrypt_nsig(params["n"]) + params["n"] = n if n + + if token = CONFIG.po_token + params["pot"] = token + end + + url.query_params = params + LOGGER.trace("convert_url: new url is '#{url}'") + + return url.to_s +rescue ex + LOGGER.debug("convert_url: Error when parsing video URL") + LOGGER.trace(ex.inspect_with_backtrace) + return "" +end -- cgit v1.2.3 From ccecc6d318ea80b2af3bf379b33700dcb6e16c97 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Sat, 24 Aug 2024 18:11:11 +0000 Subject: Fix lint errors introduced in #4146 and #4295 (#4876) * Ameba: Fix Naming/VariableNames Introduced in #4295 * Ameba: Fix Naming/PredicateName Introduced in #4146 --- src/invidious/channels/about.cr | 6 +++--- src/invidious/routes/search.cr | 2 +- src/invidious/search/query.cr | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 1380044a..13909527 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -50,12 +50,12 @@ def get_about_info(ucid, locale) : AboutChannel total_views = 0_i64 joined = Time.unix(0) - if ageGate = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer") + if age_gate_renderer = initdata.dig?("contents", "twoColumnBrowseResultsRenderer", "tabs", 0, "tabRenderer", "content", "sectionListRenderer", "contents", 0, "channelAgeGateRenderer") description_node = nil - author = ageGate["channelTitle"].as_s + author = age_gate_renderer["channelTitle"].as_s ucid = initdata.dig("responseContext", "serviceTrackingParams", 0, "params", 0, "value").as_s author_url = "https://www.youtube.com/channel/#{ucid}" - author_thumbnail = ageGate.dig("avatar", "thumbnails", 0, "url").as_s + author_thumbnail = age_gate_renderer.dig("avatar", "thumbnails", 0, "url").as_s banner = nil is_family_friendly = false is_age_gated = true diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 85aa1c7e..44970922 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -53,7 +53,7 @@ module Invidious::Routes::Search # An URL was copy/pasted in the search box. # Redirect the user to the appropriate page. - if query.is_url? + if query.url? return env.redirect UrlSanitizer.process(query.text).to_s end diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr index a93bb3f9..c8e8cf7f 100644 --- a/src/invidious/search/query.cr +++ b/src/invidious/search/query.cr @@ -149,7 +149,7 @@ module Invidious::Search end # Checks if the query is a standalone URL - def is_url? : Bool + def url? : Bool # If the smart features have been inhibited, don't go further. return false if @inhibit_ssf -- cgit v1.2.3 From 1124dd645d0db872b01a0c476c205da057a8fd04 Mon Sep 17 00:00:00 2001 From: syeopite Date: Wed, 22 May 2024 11:29:28 -0700 Subject: Use `make_client` instead of calling `HTTP::Client` Using `make_client` to create `HTTP::Client`, allows for a simple way to easily add logic to all `HTTP::Client` initialized within Invidious. --- src/invidious/routes/api/v1/search.cr | 4 +--- src/invidious/yt_backend/connection_pool.cr | 13 ++++--------- 2 files changed, 5 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index 2922b060..6785ef73 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -31,9 +31,7 @@ module Invidious::Routes::API::V1::Search query = env.params.query["q"]? || "" begin - client = HTTP::Client.new("suggestqueries-clients6.youtube.com") - client.before_request { |r| add_yt_headers(r) } - + client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers = true) url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&gs_ri=youtube&ds=yt" response = client.get(url).body diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index ca612083..84d857ec 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -30,11 +30,8 @@ struct YoutubeConnectionPool response = yield conn rescue ex conn.close - conn = HTTP::Client.new(url) - - conn.family = CONFIG.force_resolve + conn = make_client(url) conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC - conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" response = yield conn ensure pool.release(conn) @@ -45,16 +42,14 @@ struct YoutubeConnectionPool private def build_pool DB::Pool(HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do - conn = HTTP::Client.new(url) - conn.family = CONFIG.force_resolve + conn = make_client(url, force_solve = true) conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC - conn.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" conn end end end -def make_client(url : URI, region = nil, force_resolve : Bool = false) +def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_header : Bool = false) client = HTTP::Client.new(url) # Force the usage of a specific configured IP Family @@ -62,7 +57,7 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false) client.family = CONFIG.force_resolve end - client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" + client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" || force_youtube_header client.read_timeout = 10.seconds client.connect_timeout = 10.seconds -- cgit v1.2.3 From 3af668186997d21295247ed6e31c6fd4634fa511 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Fri, 24 May 2024 13:11:14 -0700 Subject: Fix typo in argument to `make_client` Co-authored-by: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> --- src/invidious/yt_backend/connection_pool.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 84d857ec..8563cc3e 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -42,7 +42,7 @@ struct YoutubeConnectionPool private def build_pool DB::Pool(HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do - conn = make_client(url, force_solve = true) + conn = make_client(url, force_resolve = true) conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC conn end -- cgit v1.2.3 From ee89db49ba6242771921c9204d57f47f3edb8975 Mon Sep 17 00:00:00 2001 From: syeopite <70992037+syeopite@users.noreply.github.com> Date: Sun, 16 Jun 2024 15:18:21 +0000 Subject: Typo Co-authored-by: Samantaz Fox --- src/invidious/yt_backend/connection_pool.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 8563cc3e..f7227d67 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -42,7 +42,7 @@ struct YoutubeConnectionPool private def build_pool DB::Pool(HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do - conn = make_client(url, force_resolve = true) + conn = make_client(url, force_resolve: true) conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC conn end -- cgit v1.2.3 From bd48af825c27f08987ee039381a702ca91e52cb8 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sun, 16 Jun 2024 14:15:05 -0700 Subject: Search API: Fix named arg syntax to make_client --- src/invidious/routes/api/v1/search.cr | 2 +- src/invidious/yt_backend/connection_pool.cr | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/search.cr b/src/invidious/routes/api/v1/search.cr index 6785ef73..59a30745 100644 --- a/src/invidious/routes/api/v1/search.cr +++ b/src/invidious/routes/api/v1/search.cr @@ -31,7 +31,7 @@ module Invidious::Routes::API::V1::Search query = env.params.query["q"]? || "" begin - client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers = true) + client = make_client(URI.parse("https://suggestqueries-clients6.youtube.com"), force_youtube_headers: true) url = "/complete/search?client=youtube&hl=en&gl=#{region}&q=#{URI.encode_www_form(query)}&gs_ri=youtube&ds=yt" response = client.get(url).body diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index f7227d67..0dc42261 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -49,7 +49,7 @@ struct YoutubeConnectionPool end end -def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_header : Bool = false) +def make_client(url : URI, region = nil, force_resolve : Bool = false, force_youtube_headers : Bool = false) client = HTTP::Client.new(url) # Force the usage of a specific configured IP Family @@ -57,7 +57,7 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false, force_you client.family = CONFIG.force_resolve end - client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" || force_youtube_header + client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" || force_youtube_headers client.read_timeout = 10.seconds client.connect_timeout = 10.seconds -- cgit v1.2.3 From 7521902e88a4654378b5a0428f9c538d52dcb9db Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 24 Aug 2024 19:37:04 -0700 Subject: Ensure IP family is always used when force_resolve --- src/invidious/yt_backend/connection_pool.cr | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 0dc42261..eaa94158 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -31,7 +31,6 @@ struct YoutubeConnectionPool rescue ex conn.close conn = make_client(url) - conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC response = yield conn ensure pool.release(conn) @@ -42,9 +41,7 @@ struct YoutubeConnectionPool private def build_pool DB::Pool(HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do - conn = make_client(url, force_resolve: true) - conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC - conn + next make_client(url, force_resolve: true) end end end @@ -55,6 +52,7 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false, force_you # Force the usage of a specific configured IP Family if force_resolve client.family = CONFIG.force_resolve + client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC end client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" || force_youtube_headers -- cgit v1.2.3 From 46c58bd84cf2a867b897338bb2105648aed0118c Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 24 Aug 2024 19:38:02 -0700 Subject: Pool: Use force_resolve in fallback new client --- src/invidious/yt_backend/connection_pool.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index eaa94158..d474d760 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -30,7 +30,7 @@ struct YoutubeConnectionPool response = yield conn rescue ex conn.close - conn = make_client(url) + conn = make_client(url, force_resolve: true) response = yield conn ensure pool.release(conn) -- cgit v1.2.3 From 6e39b9b303930f931b5a5a60c75528c4d9db3587 Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 24 Aug 2024 19:41:39 -0700 Subject: make_client: add YouTube headers on *.youtube.com --- src/invidious/yt_backend/connection_pool.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index d474d760..bff4df72 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -55,7 +55,7 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false, force_you client.family = Socket::Family::INET if client.family == Socket::Family::UNSPEC end - client.before_request { |r| add_yt_headers(r) } if url.host == "www.youtube.com" || force_youtube_headers + client.before_request { |r| add_yt_headers(r) } if url.host.try &.ends_with?("youtube.com") || force_youtube_headers client.read_timeout = 10.seconds client.connect_timeout = 10.seconds -- cgit v1.2.3 From 480e073fa9be184b6839619c38795af582247c19 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 8 Dec 2023 18:20:17 -0800 Subject: Use HTTP pools for image requests to YouTube --- src/invidious.cr | 8 ++++++++ src/invidious/routes/images.cr | 12 +++++------- src/invidious/yt_backend/connection_pool.cr | 15 +++++++++++++++ 3 files changed, 28 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 3804197e..81db2c6c 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -92,6 +92,14 @@ SOFTWARE = { YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size) +# Image request pool + +GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size) + +# Mapping of subdomain => YoutubeConnectionPool +# This is needed as we may need to access arbitrary subdomains of ytimg +YTIMG_POOLS = {} of String => YoutubeConnectionPool + # CLI Kemal.config.extra_options do |parser| parser.banner = "Usage: invidious [arguments]" diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index b6a2e110..1964d597 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -32,7 +32,7 @@ module Invidious::Routes::Images } begin - HTTP::Client.get("https://yt3.ggpht.com#{url}") do |resp| + GGPHT_POOL.client &.get(url) do |resp| return request_proc.call(resp) end rescue ex @@ -80,7 +80,7 @@ module Invidious::Routes::Images } begin - HTTP::Client.get("https://#{authority}.ytimg.com#{url}") do |resp| + get_ytimg_pool(authority).client &.get(url) do |resp| return request_proc.call(resp) end rescue ex @@ -119,7 +119,7 @@ module Invidious::Routes::Images } begin - HTTP::Client.get("https://i9.ytimg.com#{url}") do |resp| + get_ytimg_pool("i9").client &.get(url) do |resp| return request_proc.call(resp) end rescue ex @@ -165,8 +165,7 @@ module Invidious::Routes::Images if name == "maxres.jpg" build_thumbnails(id).each do |thumb| thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg" - # This can likely be optimized into a (small) pool sometime in the future. - if HTTP::Client.head("https://i.ytimg.com#{thumbnail_resource_path}").status_code == 200 + if get_ytimg_pool("i9").client &.head(thumbnail_resource_path).status_code == 200 name = thumb[:url] + ".jpg" break end @@ -199,8 +198,7 @@ module Invidious::Routes::Images } begin - # This can likely be optimized into a (small) pool sometime in the future. - HTTP::Client.get("https://i.ytimg.com#{url}") do |resp| + get_ytimg_pool("i").client &.get(url) do |resp| return request_proc.call(resp) end rescue ex diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index ca612083..26bf2773 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -77,3 +77,18 @@ def make_client(url : URI, region = nil, force_resolve : Bool = false, &) client.close end end + +# Fetches a HTTP pool for the specified subdomain of ytimg.com +# +# Creates a new one when the specified pool for the subdomain does not exist +def get_ytimg_pool(subdomain) + if pool = YTIMG_POOLS[subdomain]? + return pool + else + LOGGER.info("ytimg_pool: Creating a new HTTP pool for \"https://#{subdomain}.ytimg.com\"") + pool = YoutubeConnectionPool.new(URI.parse("https://#{subdomain}.ytimg.com"), capacity: CONFIG.pool_size) + YTIMG_POOLS[subdomain] = pool + + return pool + end +end -- cgit v1.2.3 From 52bc9aa328e44ff32bb1d7f2e05625e4080459c7 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 8 Dec 2023 18:42:40 -0800 Subject: Refactor duplicate logic in image routes --- src/invidious/routes/images.cr | 95 +++++++++--------------------------------- 1 file changed, 20 insertions(+), 75 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index 1964d597..7fdd33b0 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -11,29 +11,9 @@ module Invidious::Routes::Images end end - # We're encapsulating this into a proc in order to easily reuse this - # portion of the code for each request block below. - request_proc = ->(response : HTTP::Client::Response) { - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 - env.response.headers.delete("Transfer-Encoding") - return - end - - proxy_file(response, env) - } - begin GGPHT_POOL.client &.get(url) do |resp| - return request_proc.call(resp) + return self.proxy_image(env, resp) end rescue ex end @@ -61,27 +41,9 @@ module Invidious::Routes::Images end end - request_proc = ->(response : HTTP::Client::Response) { - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Connection"] = "close" - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 - return env.response.headers.delete("Transfer-Encoding") - end - - proxy_file(response, env) - } - begin get_ytimg_pool(authority).client &.get(url) do |resp| - return request_proc.call(resp) + return self.proxy_image(env, resp) end rescue ex end @@ -101,26 +63,9 @@ module Invidious::Routes::Images end end - request_proc = ->(response : HTTP::Client::Response) { - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end - end - - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - return env.response.headers.delete("Transfer-Encoding") - end - - proxy_file(response, env) - } - begin get_ytimg_pool("i9").client &.get(url) do |resp| - return request_proc.call(resp) + return self.proxy_image(env, resp) end rescue ex end @@ -180,28 +125,28 @@ module Invidious::Routes::Images end end - request_proc = ->(response : HTTP::Client::Response) { - env.response.status_code = response.status_code - response.headers.each do |key, value| - if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) - env.response.headers[key] = value - end + begin + get_ytimg_pool("i").client &.get(url) do |resp| + return self.proxy_image(env, resp) end + rescue ex + end + end - env.response.headers["Access-Control-Allow-Origin"] = "*" - - if response.status_code >= 300 && response.status_code != 404 - return env.response.headers.delete("Transfer-Encoding") + private def self.proxy_image(env, response) + env.response.status_code = response.status_code + response.headers.each do |key, value| + if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) + env.response.headers[key] = value end + end - proxy_file(response, env) - } + env.response.headers["Access-Control-Allow-Origin"] = "*" - begin - get_ytimg_pool("i").client &.get(url) do |resp| - return request_proc.call(resp) - end - rescue ex + if response.status_code >= 300 + return env.response.headers.delete("Transfer-Encoding") end + + return proxy_file(response, env) end end -- cgit v1.2.3 From 06e1a508e8dc5417a61a02ad1eb08e94fb24ae99 Mon Sep 17 00:00:00 2001 From: syeopite Date: Fri, 8 Dec 2023 18:52:11 -0800 Subject: Fix headers not being added in image requests Regression from #2364 --- src/invidious/routes/images.cr | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index 7fdd33b0..c4197746 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -12,7 +12,7 @@ module Invidious::Routes::Images end begin - GGPHT_POOL.client &.get(url) do |resp| + GGPHT_POOL.client &.get(url, headers) do |resp| return self.proxy_image(env, resp) end rescue ex @@ -42,7 +42,7 @@ module Invidious::Routes::Images end begin - get_ytimg_pool(authority).client &.get(url) do |resp| + get_ytimg_pool(authority).client &.get(url, headers) do |resp| return self.proxy_image(env, resp) end rescue ex @@ -64,7 +64,7 @@ module Invidious::Routes::Images end begin - get_ytimg_pool("i9").client &.get(url) do |resp| + get_ytimg_pool("i9").client &.get(url, headers) do |resp| return self.proxy_image(env, resp) end rescue ex @@ -110,7 +110,7 @@ module Invidious::Routes::Images if name == "maxres.jpg" build_thumbnails(id).each do |thumb| thumbnail_resource_path = "/vi/#{id}/#{thumb[:url]}.jpg" - if get_ytimg_pool("i9").client &.head(thumbnail_resource_path).status_code == 200 + if get_ytimg_pool("i9").client &.head(thumbnail_resource_path, headers).status_code == 200 name = thumb[:url] + ".jpg" break end @@ -126,7 +126,7 @@ module Invidious::Routes::Images end begin - get_ytimg_pool("i").client &.get(url) do |resp| + get_ytimg_pool("i").client &.get(url, headers) do |resp| return self.proxy_image(env, resp) end rescue ex -- cgit v1.2.3 From 4bc77b81bf994336e324d84ab82a362b330c827d Mon Sep 17 00:00:00 2001 From: syeopite Date: Sat, 23 Dec 2023 13:47:47 -0800 Subject: Move YTIMG_POOLS to connection_pool.cr --- src/invidious.cr | 4 ---- src/invidious/yt_backend/connection_pool.cr | 32 ++++++++++++++++------------- 2 files changed, 18 insertions(+), 18 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 81db2c6c..e0e72415 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -96,10 +96,6 @@ YT_POOL = YoutubeConnectionPool.new(YT_URL, capacity: CONFIG.pool_size) GGPHT_POOL = YoutubeConnectionPool.new(URI.parse("https://yt3.ggpht.com"), capacity: CONFIG.pool_size) -# Mapping of subdomain => YoutubeConnectionPool -# This is needed as we may need to access arbitrary subdomains of ytimg -YTIMG_POOLS = {} of String => YoutubeConnectionPool - # CLI Kemal.config.extra_options do |parser| parser.banner = "Usage: invidious [arguments]" diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index 26bf2773..646d0d1a 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -1,17 +1,6 @@ -def add_yt_headers(request) - request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal" - request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" - - request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" - request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" - request.headers["Accept-Language"] ||= "en-us,en;q=0.5" - - # Preserve original cookies and add new YT consent cookie for EU servers - request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}" - if !CONFIG.cookies.empty? - request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" - end -end +# Mapping of subdomain => YoutubeConnectionPool +# This is needed as we may need to access arbitrary subdomains of ytimg +private YTIMG_POOLS = {} of String => YoutubeConnectionPool struct YoutubeConnectionPool property! url : URI @@ -54,6 +43,21 @@ struct YoutubeConnectionPool end end +def add_yt_headers(request) + request.headers.delete("User-Agent") if request.headers["User-Agent"] == "Crystal" + request.headers["User-Agent"] ||= "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + + request.headers["Accept-Charset"] ||= "ISO-8859-1,utf-8;q=0.7,*;q=0.7" + request.headers["Accept"] ||= "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" + request.headers["Accept-Language"] ||= "en-us,en;q=0.5" + + # Preserve original cookies and add new YT consent cookie for EU servers + request.headers["Cookie"] = "#{request.headers["cookie"]?}; CONSENT=PENDING+#{Random.rand(100..999)}" + if !CONFIG.cookies.empty? + request.headers["Cookie"] = "#{(CONFIG.cookies.map { |c| "#{c.name}=#{c.value}" }).join("; ")}; #{request.headers["cookie"]?}" + end +end + def make_client(url : URI, region = nil, force_resolve : Bool = false) client = HTTP::Client.new(url) -- cgit v1.2.3 From 003c6f81dcf6399d1fa808866d0806b915a713ee Mon Sep 17 00:00:00 2001 From: syeopite Date: Mon, 8 Jan 2024 14:13:38 -0800 Subject: Preserve connection close header of get_storyboard --- src/invidious/routes/images.cr | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index c4197746..251258ec 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -41,9 +41,14 @@ module Invidious::Routes::Images end end + # A callable proc to be used inside #proxy_image + callable_proc = ->(env : HTTP::Server::Context) { + env.response.headers["Connection"] = "close" + } + begin get_ytimg_pool(authority).client &.get(url, headers) do |resp| - return self.proxy_image(env, resp) + return self.proxy_image(env, resp, callable_proc: callable_proc) end rescue ex end @@ -133,7 +138,7 @@ module Invidious::Routes::Images end end - private def self.proxy_image(env, response) + private def self.proxy_image(env, response, callable_proc = nil) env.response.status_code = response.status_code response.headers.each do |key, value| if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) @@ -143,6 +148,10 @@ module Invidious::Routes::Images env.response.headers["Access-Control-Allow-Origin"] = "*" + if callable_proc + callable_proc.call(env) + end + if response.status_code >= 300 return env.response.headers.delete("Transfer-Encoding") end -- cgit v1.2.3 From 75b68618ab14a9f884ee7215a467bc510e8bd2c2 Mon Sep 17 00:00:00 2001 From: syeopite Date: Thu, 25 Apr 2024 13:28:58 -0700 Subject: Remove useless proc usage in images.cr --- src/invidious/routes/images.cr | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/images.cr b/src/invidious/routes/images.cr index 251258ec..639697db 100644 --- a/src/invidious/routes/images.cr +++ b/src/invidious/routes/images.cr @@ -41,14 +41,10 @@ module Invidious::Routes::Images end end - # A callable proc to be used inside #proxy_image - callable_proc = ->(env : HTTP::Server::Context) { - env.response.headers["Connection"] = "close" - } - begin get_ytimg_pool(authority).client &.get(url, headers) do |resp| - return self.proxy_image(env, resp, callable_proc: callable_proc) + env.response.headers["Connection"] = "close" + return self.proxy_image(env, resp) end rescue ex end @@ -138,7 +134,7 @@ module Invidious::Routes::Images end end - private def self.proxy_image(env, response, callable_proc = nil) + private def self.proxy_image(env, response) env.response.status_code = response.status_code response.headers.each do |key, value| if !RESPONSE_HEADERS_BLACKLIST.includes?(key.downcase) @@ -148,10 +144,6 @@ module Invidious::Routes::Images env.response.headers["Access-Control-Allow-Origin"] = "*" - if callable_proc - callable_proc.call(env) - end - if response.status_code >= 300 return env.response.headers.delete("Transfer-Encoding") end -- cgit v1.2.3 From 157c4c3e9827921b9bce1908e9d294e27bfe0ed5 Mon Sep 17 00:00:00 2001 From: Dmitry Sandalov Date: Wed, 28 Aug 2024 23:54:31 +0200 Subject: Fix 'invalid byte sequence' error when subscribing to playlists --- src/invidious/playlists.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 3e6eef95..3cbab617 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -270,7 +270,7 @@ end def subscribe_playlist(user, playlist) playlist = InvidiousPlaylist.new({ - title: playlist.title.byte_slice(0, 150), + title: playlist.title.chars[0, 150].join, id: playlist.id, author: user.email, description: "", # Max 5000 characters -- cgit v1.2.3 From bd34659ff60bd049a2503f2d5e59d353d01840d8 Mon Sep 17 00:00:00 2001 From: Dmitry Sandalov Date: Thu, 29 Aug 2024 22:47:59 +0200 Subject: Fix 'invalid byte sequence' error when subscribing to playlists ([] accessor with range) --- src/invidious/playlists.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index 3cbab617..a51e88b4 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -270,7 +270,7 @@ end def subscribe_playlist(user, playlist) playlist = InvidiousPlaylist.new({ - title: playlist.title.chars[0, 150].join, + title: playlist.title[..150], id: playlist.id, author: user.email, description: "", # Max 5000 characters -- cgit v1.2.3 From 5e899d73a9ae86275c01b038c374f68787f03ea6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 2 Sep 2024 18:14:57 +0200 Subject: Search: Fix for youtu.be URL in sanitizer --- src/invidious/yt_backend/url_sanitizer.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/url_sanitizer.cr b/src/invidious/yt_backend/url_sanitizer.cr index 725382ee..d539dadb 100644 --- a/src/invidious/yt_backend/url_sanitizer.cr +++ b/src/invidious/yt_backend/url_sanitizer.cr @@ -111,7 +111,7 @@ module UrlSanitizer new_uri.path = "/watch" new_params = copy_params(unsafe_uri.query_params, :watch) - new_params["id"] = breadcrumbs[0] + new_params["v"] = breadcrumbs[0] new_uri.query_params = new_params end -- cgit v1.2.3 From de918b9234b99f91a0a364fc675533147581eb2e Mon Sep 17 00:00:00 2001 From: "Émilien (perso)" <4016501+unixfox@users.noreply.github.com> Date: Mon, 16 Sep 2024 23:42:43 +0200 Subject: use web screen embed for fixing potoken functionality (#4923) * use web screen embed for fixing potoken functionality * use web screen embed only for getting streamingData + disable tv screen on po_token --- src/invidious/videos/parser.cr | 13 +++++++++---- src/invidious/yt_backend/youtube_api.cr | 3 ++- 2 files changed, 11 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 95fa3d79..c17e596c 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -102,6 +102,12 @@ def extract_video_info(video_id : String) new_player_response = nil + # Use the WEB embed client when po_token is configured because it only works on this client + if CONFIG.po_token + client_config.client_type = YoutubeAPI::ClientType::WebEmbeddedPlayer + new_player_response = try_fetch_streaming_data(video_id, client_config) + end + # Don't use Android client if po_token is passed because po_token doesn't # work for Android client. if reason.nil? && CONFIG.po_token.nil? @@ -114,10 +120,9 @@ def extract_video_info(video_id : String) end # Last hope - # Only trigger if reason found and po_token or didn't work wth Android client. - # TvHtml5ScreenEmbed now requires sig helper for it to work but po_token is not required - # if the IP address is not blocked. - if CONFIG.po_token && reason || CONFIG.po_token.nil? && new_player_response.nil? + # Only trigger if reason found or didn't work wth Android client. + # TvHtml5ScreenEmbed now requires sig helper for it to work but doesn't work with po_token. + if reason && CONFIG.po_token.nil? client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed new_player_response = try_fetch_streaming_data(video_id, client_config) end diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index d66bf7aa..6d6c72d1 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -291,8 +291,9 @@ module YoutubeAPI end if client_config.screen == "EMBED" + # embedUrl https://www.google.com allow loading video that are configured not embeddable client_context["thirdParty"] = { - "embedUrl" => "https://www.youtube.com/embed/#{video_id}", + "embedUrl" => "https://www.google.com/", } of String => String | Int64 end -- cgit v1.2.3 From cec3cfba774926100095246d80be401155df2f68 Mon Sep 17 00:00:00 2001 From: Emilien Devos <4016501+unixfox@users.noreply.github.com> Date: Tue, 17 Sep 2024 00:22:06 +0200 Subject: Revert "use web screen embed for fixing potoken functionality (#4923)" This reverts commit de918b9234b99f91a0a364fc675533147581eb2e. The code doesn't work as expected. Reverting --- src/invidious/videos/parser.cr | 13 ++++--------- src/invidious/yt_backend/youtube_api.cr | 3 +-- 2 files changed, 5 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index c17e596c..95fa3d79 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -102,12 +102,6 @@ def extract_video_info(video_id : String) new_player_response = nil - # Use the WEB embed client when po_token is configured because it only works on this client - if CONFIG.po_token - client_config.client_type = YoutubeAPI::ClientType::WebEmbeddedPlayer - new_player_response = try_fetch_streaming_data(video_id, client_config) - end - # Don't use Android client if po_token is passed because po_token doesn't # work for Android client. if reason.nil? && CONFIG.po_token.nil? @@ -120,9 +114,10 @@ def extract_video_info(video_id : String) end # Last hope - # Only trigger if reason found or didn't work wth Android client. - # TvHtml5ScreenEmbed now requires sig helper for it to work but doesn't work with po_token. - if reason && CONFIG.po_token.nil? + # Only trigger if reason found and po_token or didn't work wth Android client. + # TvHtml5ScreenEmbed now requires sig helper for it to work but po_token is not required + # if the IP address is not blocked. + if CONFIG.po_token && reason || CONFIG.po_token.nil? && new_player_response.nil? client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed new_player_response = try_fetch_streaming_data(video_id, client_config) end diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 6d6c72d1..d66bf7aa 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -291,9 +291,8 @@ module YoutubeAPI end if client_config.screen == "EMBED" - # embedUrl https://www.google.com allow loading video that are configured not embeddable client_context["thirdParty"] = { - "embedUrl" => "https://www.google.com/", + "embedUrl" => "https://www.youtube.com/embed/#{video_id}", } of String => String | Int64 end -- cgit v1.2.3 From d9df90b5e3ab6f738907c1bfaf96f0407368d842 Mon Sep 17 00:00:00 2001 From: "Émilien (perso)" <4016501+unixfox@users.noreply.github.com> Date: Fri, 20 Sep 2024 00:19:13 +0200 Subject: use WEB_CREATOR when po_token with WEB_EMBED as a fallback (#4928) * use WEB_CREATOR when po_token with WEB_EMBEDDED_PLAYER as a fallback * remove unrelated comment Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com> --------- Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com> --- src/invidious/videos/parser.cr | 25 ++++++++++++++++++------- src/invidious/yt_backend/youtube_api.cr | 12 +++++++++++- 2 files changed, 29 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 95fa3d79..ca6500ed 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -53,6 +53,10 @@ end def extract_video_info(video_id : String) # Init client config for the API client_config = YoutubeAPI::ClientConfig.new + # Use the WEB_CREATOR when po_token is configured because it fully only works on this client + if CONFIG.po_token + client_config.client_type = YoutubeAPI::ClientType::WebCreator + end # Fetch data from the player endpoint player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config) @@ -102,6 +106,13 @@ def extract_video_info(video_id : String) new_player_response = nil + # Second try in case WEB_EMBEDDED_PLAYER doesn't work with po_token. + # Only trigger if reason found and po_token configured. + if reason && CONFIG.po_token + client_config.client_type = YoutubeAPI::ClientType::WebEmbeddedPlayer + new_player_response = try_fetch_streaming_data(video_id, client_config) + end + # Don't use Android client if po_token is passed because po_token doesn't # work for Android client. if reason.nil? && CONFIG.po_token.nil? @@ -114,10 +125,9 @@ def extract_video_info(video_id : String) end # Last hope - # Only trigger if reason found and po_token or didn't work wth Android client. - # TvHtml5ScreenEmbed now requires sig helper for it to work but po_token is not required - # if the IP address is not blocked. - if CONFIG.po_token && reason || CONFIG.po_token.nil? && new_player_response.nil? + # Only trigger if reason found or didn't work wth Android client. + # TvHtml5ScreenEmbed now requires sig helper for it to work but doesn't work with po_token. + if reason && CONFIG.po_token.nil? client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed new_player_response = try_fetch_streaming_data(video_id, client_config) end @@ -185,10 +195,11 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any end video_details = player_response.dig?("videoDetails") - microformat = player_response.dig?("microformat", "playerMicroformatRenderer") + if !(microformat = player_response.dig?("microformat", "playerMicroformatRenderer")) + microformat = {} of String => JSON::Any + end raise BrokenTubeException.new("videoDetails") if !video_details - raise BrokenTubeException.new("microformat") if !microformat # Basic video infos @@ -225,7 +236,7 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any .try &.as_a.map &.as_s || [] of String allow_ratings = video_details["allowRatings"]?.try &.as_bool - family_friendly = microformat["isFamilySafe"].try &.as_bool + family_friendly = microformat["isFamilySafe"]?.try &.as_bool is_listed = video_details["isCrawlable"]?.try &.as_bool is_upcoming = video_details["isUpcoming"]?.try &.as_bool diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index d66bf7aa..99ec6e63 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -29,6 +29,7 @@ module YoutubeAPI WebEmbeddedPlayer WebMobile WebScreenEmbed + WebCreator Android AndroidEmbeddedPlayer @@ -80,6 +81,14 @@ module YoutubeAPI os_version: WINDOWS_VERSION, platform: "DESKTOP", }, + ClientType::WebCreator => { + name: "WEB_CREATOR", + name_proto: "62", + version: "1.20220918", + os_name: "Windows", + os_version: WINDOWS_VERSION, + platform: "DESKTOP", + }, # Android @@ -291,8 +300,9 @@ module YoutubeAPI end if client_config.screen == "EMBED" + # embedUrl https://www.google.com allow loading almost all video that are configured not embeddable client_context["thirdParty"] = { - "embedUrl" => "https://www.youtube.com/embed/#{video_id}", + "embedUrl" => "https://www.google.com/", } of String => String | Int64 end -- cgit v1.2.3 From a021b93063f3956fc9bb3cce0fb56ea252422738 Mon Sep 17 00:00:00 2001 From: "Émilien (perso)" <4016501+unixfox@users.noreply.github.com> Date: Fri, 20 Sep 2024 02:05:41 +0200 Subject: Update latest version WEB_CREATOR + fix comment web embed (#4930) * Update to latest version WEB_CREATOR * fix comment about using web embed as a fallback --- src/invidious/videos/parser.cr | 2 +- src/invidious/yt_backend/youtube_api.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index ca6500ed..811a0a03 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -106,7 +106,7 @@ def extract_video_info(video_id : String) new_player_response = nil - # Second try in case WEB_EMBEDDED_PLAYER doesn't work with po_token. + # Second try in case WEB_CREATOR doesn't work with po_token. # Only trigger if reason found and po_token configured. if reason && CONFIG.po_token client_config.client_type = YoutubeAPI::ClientType::WebEmbeddedPlayer diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 99ec6e63..baa3cd92 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -84,7 +84,7 @@ module YoutubeAPI ClientType::WebCreator => { name: "WEB_CREATOR", name_proto: "62", - version: "1.20220918", + version: "1.20240918.03.00", os_name: "Windows", os_version: WINDOWS_VERSION, platform: "DESKTOP", -- cgit v1.2.3 From f8ec3123286e63148123ccba781dcd699f705e1d Mon Sep 17 00:00:00 2001 From: Fijxu Date: Thu, 19 Sep 2024 21:24:20 -0300 Subject: Logger: Add color support for different log levels --- config/config.example.yml | 9 +++++++++ src/invidious.cr | 5 ++++- src/invidious/config.cr | 2 ++ src/invidious/helpers/logger.cr | 19 +++++++++++++++++-- 4 files changed, 32 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 219aa03f..37b932ea 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -222,6 +222,15 @@ https_only: false ## #log_level: Info +## +## Enables colors in logs. Useful for debugging purposes +## This is overridden if "-k" or "--colorize" +## are passed on the command line. +## +## Accepted values: true, false +## Default: false +## +#colorize_logs: false # ----------------------------- # Features diff --git a/src/invidious.cr b/src/invidious.cr index 3804197e..d9a479d1 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -117,6 +117,9 @@ Kemal.config.extra_options do |parser| parser.on("-l LEVEL", "--log-level=LEVEL", "Log level, one of #{LogLevel.values} (default: #{CONFIG.log_level})") do |log_level| CONFIG.log_level = LogLevel.parse(log_level) end + parser.on("-k", "--colorize", "Colorize logs") do + CONFIG.colorize_logs = true + end parser.on("-v", "--version", "Print version") do puts SOFTWARE.to_pretty_json exit @@ -133,7 +136,7 @@ if CONFIG.output.upcase != "STDOUT" FileUtils.mkdir_p(File.dirname(CONFIG.output)) end OUTPUT = CONFIG.output.upcase == "STDOUT" ? STDOUT : File.open(CONFIG.output, mode: "a") -LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level) +LOGGER = Invidious::LogHandler.new(OUTPUT, CONFIG.log_level, CONFIG.colorize_logs) # Check table integrity Invidious::Database.check_integrity(CONFIG) diff --git a/src/invidious/config.cr b/src/invidious/config.cr index c4ddcdb3..d8543d35 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -68,6 +68,8 @@ class Config property output : String = "STDOUT" # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr property log_level : LogLevel = LogLevel::Info + # Enables colors in logs. Useful for debugging purposes + property colorize_logs : Bool = false # Database configuration with separate parameters (username, hostname, etc) property db : DBConfig? = nil diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr index b443073e..36a3a7f9 100644 --- a/src/invidious/helpers/logger.cr +++ b/src/invidious/helpers/logger.cr @@ -1,3 +1,5 @@ +require "colorize" + enum LogLevel All = 0 Trace = 1 @@ -10,7 +12,7 @@ enum LogLevel end class Invidious::LogHandler < Kemal::BaseLogHandler - def initialize(@io : IO = STDOUT, @level = LogLevel::Debug) + def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, @color : Bool = true) end def call(context : HTTP::Server::Context) @@ -39,10 +41,23 @@ class Invidious::LogHandler < Kemal::BaseLogHandler @io.flush end + def color(level) + case level + when LogLevel::Trace then :cyan + when LogLevel::Debug then :green + when LogLevel::Info then :white + when LogLevel::Warn then :yellow + when LogLevel::Error then :red + when LogLevel::Fatal then :magenta + else :default + end + end + {% for level in %w(trace debug info warn error fatal) %} def {{level.id}}(message : String) if LogLevel::{{level.id.capitalize}} >= @level - puts("#{Time.utc} [{{level.id}}] #{message}") + puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}})).toggle(@color)) + end end {% end %} -- cgit v1.2.3 From d77afdcf00f55a4455fb84dd90c4e5773167b759 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Fri, 20 Sep 2024 00:32:27 -0300 Subject: Logger: Make colorize_logs true by default --- config/config.example.yml | 6 ++++-- src/invidious/config.cr | 2 +- src/invidious/helpers/logger.cr | 5 ++--- 3 files changed, 7 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 37b932ea..fefc28be 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -226,11 +226,13 @@ https_only: false ## Enables colors in logs. Useful for debugging purposes ## This is overridden if "-k" or "--colorize" ## are passed on the command line. +## Colors are also disabled if the environment variable +## NO_COLOR is present and has any value ## ## Accepted values: true, false -## Default: false +## Default: true ## -#colorize_logs: false +#colorize_logs: true # ----------------------------- # Features diff --git a/src/invidious/config.cr b/src/invidious/config.cr index d8543d35..054f8db7 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -69,7 +69,7 @@ class Config # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr property log_level : LogLevel = LogLevel::Info # Enables colors in logs. Useful for debugging purposes - property colorize_logs : Bool = false + property colorize_logs : Bool = true # Database configuration with separate parameters (username, hostname, etc) property db : DBConfig? = nil diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr index 36a3a7f9..3c425ff4 100644 --- a/src/invidious/helpers/logger.cr +++ b/src/invidious/helpers/logger.cr @@ -12,7 +12,7 @@ enum LogLevel end class Invidious::LogHandler < Kemal::BaseLogHandler - def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, @color : Bool = true) + def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, @use_color : Bool = true) end def call(context : HTTP::Server::Context) @@ -56,8 +56,7 @@ class Invidious::LogHandler < Kemal::BaseLogHandler {% for level in %w(trace debug info warn error fatal) %} def {{level.id}}(message : String) if LogLevel::{{level.id.capitalize}} >= @level - puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}})).toggle(@color)) - + puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}})).toggle(@use_color)) end end {% end %} -- cgit v1.2.3 From b2a83991d16cc9fa65f71309cd4a745f005cdf61 Mon Sep 17 00:00:00 2001 From: absidue <48293849+absidue@users.noreply.github.com> Date: Fri, 20 Sep 2024 18:46:00 +0200 Subject: Fix parsing live_now and premiere_timestamp --- src/invidious/videos/parser.cr | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 811a0a03..c28ba634 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -224,8 +224,17 @@ def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any premiere_timestamp = microformat.dig?("liveBroadcastDetails", "startTimestamp") .try { |t| Time.parse_rfc3339(t.as_s) } + premiere_timestamp ||= player_response.dig?( + "playabilityStatus", "liveStreamability", + "liveStreamabilityRenderer", "offlineSlate", + "liveStreamOfflineSlateRenderer", "scheduledStartTime" + ) + .try &.as_s.to_i64 + .try { |t| Time.unix(t) } + live_now = microformat.dig?("liveBroadcastDetails", "isLiveNow") - .try &.as_bool || false + .try &.as_bool + live_now ||= video_details.dig?("isLive").try &.as_bool || false post_live_dvr = video_details.dig?("isPostLiveDvr") .try &.as_bool || false -- cgit v1.2.3 From 17b525f2a66f6e832ccdc74522feebe68f73d9de Mon Sep 17 00:00:00 2001 From: Fijxu Date: Fri, 27 Sep 2024 18:08:21 -0300 Subject: Logger: colorize_logs false by default --- config/config.example.yml | 2 +- src/invidious/config.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index fefc28be..d79622ad 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -232,7 +232,7 @@ https_only: false ## Accepted values: true, false ## Default: true ## -#colorize_logs: true +#colorize_logs: false # ----------------------------- # Features diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 054f8db7..d8543d35 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -69,7 +69,7 @@ class Config # Default log level, valid YAML values are ints and strings, see src/invidious/helpers/logger.cr property log_level : LogLevel = LogLevel::Info # Enables colors in logs. Useful for debugging purposes - property colorize_logs : Bool = true + property colorize_logs : Bool = false # Database configuration with separate parameters (username, hostname, etc) property db : DBConfig? = nil -- cgit v1.2.3 From 2e649363d2c985737ce8379c383adbe632607c39 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Tue, 20 Aug 2024 02:11:43 -0400 Subject: Parse more metadata badges for SearchVideos --- src/invidious/helpers/serialized_yt_data.cr | 14 ++++++++++ src/invidious/routes/feeds.cr | 7 +++++ src/invidious/yt_backend/extractors.cr | 40 ++++++++++++++++++++++++++--- 3 files changed, 58 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 463d5557..c0ca789e 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -13,6 +13,13 @@ struct SearchVideo property premium : Bool property premiere_timestamp : Time? property author_verified : Bool + property is_new : Bool + property is_4k : Bool + property is_8k : Bool + property is_vr180 : Bool + property is_vr360 : Bool + property is_3d : Bool + property has_captions : Bool def to_xml(auto_generated, query_params, xml : XML::Builder) query_params["v"] = self.id @@ -95,6 +102,13 @@ struct SearchVideo if self.premiere_timestamp json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix end + json.field "isNew", self.is_new + json.field "is4k", self.is_4k + json.field "is8k", self.is_8k + json.field "isVR180", is_vr180 + json.field "isVR360", is_vr360 + json.field "is3d", is_3d + json.field "hasCaptions", self.has_captions end end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index e20a7139..b76aeb46 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -197,6 +197,13 @@ module Invidious::Routes::Feeds premium: false, premiere_timestamp: nil, author_verified: false, + is_new: false, + is_4k: false, + is_8k: false, + is_vr180: false, + is_vr360: false, + is_3d: false, + has_captions: false, }) end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 38dc2c04..48c2155b 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -110,6 +110,13 @@ private module Parsers live_now = false premium = false + is_new = false + is_4k = false + is_8k = false + is_vr180 = false + is_vr360 = false + is_3d = false + has_captions = false premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } @@ -118,12 +125,25 @@ private module Parsers case b["label"].as_s when "LIVE NOW" live_now = true - when "New", "4K", "CC" - # TODO + when "New" + is_new = true + when "4K" + is_4k = true + when "8K" + is_8k = true + when "VR180" + is_vr180 = true + when "360°" + is_vr360 = true + when "3D" + is_3d = true + when "CC" + has_captions = true when "Premium" # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] premium = true - else nil # Ignore + else # Ignore + puts b["label"].as_s end end @@ -140,6 +160,13 @@ private module Parsers premium: premium, premiere_timestamp: premiere_timestamp, author_verified: author_verified, + is_new: is_new, + is_4k: is_4k, + is_8k: is_8k, + is_vr180: is_vr180, + is_vr360: is_vr360, + is_3d: is_3d, + has_captions: has_captions, }) end @@ -567,6 +594,13 @@ private module Parsers premium: false, premiere_timestamp: Time.unix(0), author_verified: false, + is_new: false, + is_4k: false, + is_8k: false, + is_vr180: false, + is_vr360: false, + is_3d: false, + has_captions: false, }) end -- cgit v1.2.3 From 1961fc3b113ff763b46afd4a44fb5796c083c820 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sat, 24 Aug 2024 18:00:59 -0400 Subject: switch to enum flag instead of adding lots of properties to SearchVideo --- src/invidious/channels/channels.cr | 4 +-- src/invidious/helpers/serialized_yt_data.cr | 41 ++++++++++++---------- src/invidious/routes/feeds.cr | 11 +----- src/invidious/yt_backend/extractors.cr | 53 +++++++---------------------- 4 files changed, 39 insertions(+), 70 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/channels.cr b/src/invidious/channels/channels.cr index 29546e38..1478c8fc 100644 --- a/src/invidious/channels/channels.cr +++ b/src/invidious/channels/channels.cr @@ -223,7 +223,7 @@ def fetch_channel(ucid, pull_all_videos : Bool) length_seconds = channel_video.try &.length_seconds length_seconds ||= 0 - live_now = channel_video.try &.live_now + live_now = channel_video.try &.badges.live_now? live_now ||= false premiere_timestamp = channel_video.try &.premiere_timestamp @@ -275,7 +275,7 @@ def fetch_channel(ucid, pull_all_videos : Bool) ucid: video.ucid, author: video.author, length_seconds: video.length_seconds, - live_now: video.live_now, + live_now: video.badges.live_now?, premiere_timestamp: video.premiere_timestamp, views: video.views, }) diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index c0ca789e..8f57954a 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -1,3 +1,16 @@ +@[Flags] +enum VideoBadges + LiveNow + Premium + ThreeD + FourK + New + EightK + VR180 + VR360 + CCommons +end + struct SearchVideo include DB::Serializable @@ -9,17 +22,9 @@ struct SearchVideo property views : Int64 property description_html : String property length_seconds : Int32 - property live_now : Bool - property premium : Bool property premiere_timestamp : Time? property author_verified : Bool - property is_new : Bool - property is_4k : Bool - property is_8k : Bool - property is_vr180 : Bool - property is_vr360 : Bool - property is_3d : Bool - property has_captions : Bool + property badges : VideoBadges def to_xml(auto_generated, query_params, xml : XML::Builder) query_params["v"] = self.id @@ -95,20 +100,20 @@ struct SearchVideo json.field "published", self.published.to_unix json.field "publishedText", translate(locale, "`x` ago", recode_date(self.published, locale)) json.field "lengthSeconds", self.length_seconds - json.field "liveNow", self.live_now - json.field "premium", self.premium + json.field "liveNow", self.badges.live_now? + json.field "premium", self.badges.premium? json.field "isUpcoming", self.upcoming? if self.premiere_timestamp json.field "premiereTimestamp", self.premiere_timestamp.try &.to_unix end - json.field "isNew", self.is_new - json.field "is4k", self.is_4k - json.field "is8k", self.is_8k - json.field "isVR180", is_vr180 - json.field "isVR360", is_vr360 - json.field "is3d", is_3d - json.field "hasCaptions", self.has_captions + json.field "isNew", self.badges.new? + json.field "is4k", self.badges.four_k? + json.field "is8k", self.badges.eight_k? + json.field "isVr180", self.badges.vr180? + json.field "isVr360", self.badges.vr360? + json.field "is3d", self.badges.three_d? + json.field "hasCaptions", self.badges.c_commons? end end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index b76aeb46..ea7fb396 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -192,18 +192,9 @@ module Invidious::Routes::Feeds views: views, description_html: description_html, length_seconds: 0, - live_now: false, - paid: false, - premium: false, premiere_timestamp: nil, author_verified: false, - is_new: false, - is_4k: false, - is_8k: false, - is_vr180: false, - is_vr360: false, - is_3d: false, - has_captions: false, + badges: VideoBadges::None, }) end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 48c2155b..36f2dc4a 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -108,42 +108,31 @@ private module Parsers length_seconds = 0 end - live_now = false - premium = false - is_new = false - is_4k = false - is_8k = false - is_vr180 = false - is_vr360 = false - is_3d = false - has_captions = false - premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } - + badges = VideoBadges::None item_contents["badges"]?.try &.as_a.each do |badge| b = badge["metadataBadgeRenderer"] case b["label"].as_s when "LIVE NOW" - live_now = true + badges |= VideoBadges::LiveNow when "New" - is_new = true + badges |= VideoBadges::New when "4K" - is_4k = true + badges |= VideoBadges::FourK when "8K" - is_8k = true + badges |= VideoBadges::EightK when "VR180" - is_vr180 = true + badges |= VideoBadges::VR180 when "360°" - is_vr360 = true + badges |= VideoBadges::VR360 when "3D" - is_3d = true + badges |= VideoBadges::ThreeD when "CC" - has_captions = true + badges |= VideoBadges::CCommons when "Premium" # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] - premium = true - else # Ignore - puts b["label"].as_s + badges |= VideoBadges::Premium + else nil # Ignore end end @@ -156,17 +145,9 @@ private module Parsers views: view_count, description_html: description_html, length_seconds: length_seconds, - live_now: live_now, - premium: premium, premiere_timestamp: premiere_timestamp, author_verified: author_verified, - is_new: is_new, - is_4k: is_4k, - is_8k: is_8k, - is_vr180: is_vr180, - is_vr360: is_vr360, - is_3d: is_3d, - has_captions: has_captions, + badges: badges, }) end @@ -590,17 +571,9 @@ private module Parsers views: view_count, description_html: "", length_seconds: duration, - live_now: false, - premium: false, premiere_timestamp: Time.unix(0), author_verified: false, - is_new: false, - is_4k: false, - is_8k: false, - is_vr180: false, - is_vr360: false, - is_3d: false, - has_captions: false, + badges: VideoBadges::None, }) end -- cgit v1.2.3 From 98f1e4170b8245de635fe091c9caca1d03464f52 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Sat, 24 Aug 2024 21:04:22 -0400 Subject: Rename CCommons to ClosedCaptions --- src/invidious/helpers/serialized_yt_data.cr | 4 ++-- src/invidious/yt_backend/extractors.cr | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/serialized_yt_data.cr b/src/invidious/helpers/serialized_yt_data.cr index 8f57954a..1fef5f93 100644 --- a/src/invidious/helpers/serialized_yt_data.cr +++ b/src/invidious/helpers/serialized_yt_data.cr @@ -8,7 +8,7 @@ enum VideoBadges EightK VR180 VR360 - CCommons + ClosedCaptions end struct SearchVideo @@ -113,7 +113,7 @@ struct SearchVideo json.field "isVr180", self.badges.vr180? json.field "isVr360", self.badges.vr360? json.field "is3d", self.badges.three_d? - json.field "hasCaptions", self.badges.c_commons? + json.field "hasCaptions", self.badges.closed_captions? end end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 36f2dc4a..7c89f6c4 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -128,7 +128,7 @@ private module Parsers when "3D" badges |= VideoBadges::ThreeD when "CC" - badges |= VideoBadges::CCommons + badges |= VideoBadges::ClosedCaptions when "Premium" # TODO: Potentially available as item_contents["topStandaloneBadge"]["metadataBadgeRenderer"] badges |= VideoBadges::Premium -- cgit v1.2.3 From f6e09250cd8c8bf8df1b785381a3781b3169ac66 Mon Sep 17 00:00:00 2001 From: ChunkyProgrammer <78101139+ChunkyProgrammer@users.noreply.github.com> Date: Mon, 7 Oct 2024 11:30:33 -0400 Subject: Use "LIVE" instead of "LIVE NOW" when parsing the live_now video badge Co-authored-by: Samantaz Fox --- src/invidious/yt_backend/extractors.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 7c89f6c4..4074de86 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -113,7 +113,7 @@ private module Parsers item_contents["badges"]?.try &.as_a.each do |badge| b = badge["metadataBadgeRenderer"] case b["label"].as_s - when "LIVE NOW" + when "LIVE" badges |= VideoBadges::LiveNow when "New" badges |= VideoBadges::New -- cgit v1.2.3 From d2edd4b63fe690c248ff8709b39098fcdad0e109 Mon Sep 17 00:00:00 2001 From: Fijxu Date: Tue, 8 Oct 2024 18:36:50 -0300 Subject: fixup! Logger: Add color support for different log levels --- config/config.example.yml | 2 +- src/invidious/helpers/logger.cr | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index d79622ad..f746d1f7 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -224,7 +224,7 @@ https_only: false ## ## Enables colors in logs. Useful for debugging purposes -## This is overridden if "-k" or "--colorize" +## This is overridden if "-k" or "--colorize" ## are passed on the command line. ## Colors are also disabled if the environment variable ## NO_COLOR is present and has any value diff --git a/src/invidious/helpers/logger.cr b/src/invidious/helpers/logger.cr index 3c425ff4..03349595 100644 --- a/src/invidious/helpers/logger.cr +++ b/src/invidious/helpers/logger.cr @@ -12,7 +12,9 @@ enum LogLevel end class Invidious::LogHandler < Kemal::BaseLogHandler - def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, @use_color : Bool = true) + def initialize(@io : IO = STDOUT, @level = LogLevel::Debug, use_color : Bool = true) + Colorize.enabled = use_color + Colorize.on_tty_only! end def call(context : HTTP::Server::Context) @@ -56,7 +58,7 @@ class Invidious::LogHandler < Kemal::BaseLogHandler {% for level in %w(trace debug info warn error fatal) %} def {{level.id}}(message : String) if LogLevel::{{level.id.capitalize}} >= @level - puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}})).toggle(@use_color)) + puts("#{Time.utc} [{{level.id}}] #{message}".colorize(color(LogLevel::{{level.id.capitalize}}))) end end {% end %} -- cgit v1.2.3 From 84e4746265d6077b1537b626c9742498f9cb253c Mon Sep 17 00:00:00 2001 From: Fijxu Date: Wed, 18 Sep 2024 18:14:28 -0300 Subject: SigHelper: Reconnect to signature helper Signed-off-by: Fijxu --- src/invidious/helpers/sig_helper.cr | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/sig_helper.cr b/src/invidious/helpers/sig_helper.cr index 9e72c1c7..6d198a42 100644 --- a/src/invidious/helpers/sig_helper.cr +++ b/src/invidious/helpers/sig_helper.cr @@ -175,8 +175,9 @@ module Invidious::SigHelper @queue = {} of TransactionID => Transaction @conn : Connection + @uri_or_path : String - def initialize(uri_or_path) + def initialize(@uri_or_path) @conn = Connection.new(uri_or_path) listen end @@ -186,10 +187,26 @@ module Invidious::SigHelper LOGGER.debug("SigHelper: Multiplexor listening") - # TODO: reopen socket if unexpectedly closed spawn do loop do - receive_data + begin + receive_data + rescue ex + LOGGER.info("SigHelper: Connection to helper died with '#{ex.message}' trying to reconnect...") + # We close the socket because for some reason is not closed. + @conn.close + loop do + begin + @conn = Connection.new(@uri_or_path) + LOGGER.info("SigHelper: Reconnected to SigHelper!") + rescue ex + LOGGER.debug("SigHelper: Reconnection to helper unsuccessful with error '#{ex.message}'. Retrying") + sleep 500.milliseconds + next + end + break if !@conn.closed? + end + end Fiber.yield end end -- cgit v1.2.3 From 952b3625a0a8fb21ab04bc267f94a21c331109f6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 10 Oct 2024 20:31:22 +0200 Subject: Add "Filipino (auto-generated)" to the list of caption languages --- locales/en-US.json | 1 + src/invidious/videos/caption.cr | 1 + 2 files changed, 2 insertions(+) (limited to 'src') diff --git a/locales/en-US.json b/locales/en-US.json index 7827d9c6..c23f6bc3 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -286,6 +286,7 @@ "Esperanto": "Esperanto", "Estonian": "Estonian", "Filipino": "Filipino", + "Filipino (auto-generated)": "Filipino (auto-generated)", "Finnish": "Finnish", "French": "French", "French (auto-generated)": "French (auto-generated)", diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index 484e61d2..c811cfe1 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -123,6 +123,7 @@ module Invidious::Videos "Esperanto", "Estonian", "Filipino", + "Filipino (auto-generated)", "Finnish", "French", "French (auto-generated)", -- cgit v1.2.3 From ee728092823d8e82f71f35c31da8a27efec0f1b5 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Sat, 26 Oct 2024 12:40:31 -0400 Subject: [Alternative] Fix for channel live videos --- src/invidious/channels/videos.cr | 64 +++++++++++++++++++++++++++++----------- 1 file changed, 46 insertions(+), 18 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index 6cc30142..e29d80ed 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -23,29 +23,57 @@ def produce_channel_content_continuation(ucid, content_type, page = 1, auto_gene else 15 # Fallback to "videos" end - sort_by_numerical = - case sort_by - when "newest" then 1_i64 - when "popular" then 2_i64 - when "oldest" then 4_i64 - else 1_i64 # Fallback to "newest" - end + if content_type == "livestreams" + sort_by_numerical = + case sort_by + when "newest" then 12_i64 + when "popular" then 14_i64 + when "oldest" then 13_i64 + else 12_i64 # Fallback to "newest" + end + else + sort_by_numerical = + case sort_by + when "newest" then 1_i64 + when "popular" then 2_i64 + when "oldest" then 4_i64 + else 1_i64 # Fallback to "newest" + end + end - object_inner_1 = { - "110:embedded" => { - "3:embedded" => { - "#{content_type_numerical}:embedded" => { - "1:embedded" => { - "1:string" => object_inner_2_encoded, + if content_type == "livestreams" + object_inner_1 = { + "110:embedded" => { + "3:embedded" => { + "#{content_type_numerical}:embedded" => { + "1:embedded" => { + "1:string" => object_inner_2_encoded, + }, + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", + }, + "5:varint" => sort_by_numerical, }, - "2:embedded" => { - "1:string" => "00000000-0000-0000-0000-000000000000", + }, + }, + } + else + object_inner_1 = { + "110:embedded" => { + "3:embedded" => { + "#{content_type_numerical}:embedded" => { + "1:embedded" => { + "1:string" => object_inner_2_encoded, + }, + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", + }, + "3:varint" => sort_by_numerical, }, - "3:varint" => sort_by_numerical, }, }, - }, - } + } + end object_inner_1_encoded = object_inner_1 .try { |i| Protodec::Any.cast_json(i) } -- cgit v1.2.3 From 711d52d47fcff4cc376551a81fba47dcaeb23e0c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 29 Oct 2024 17:26:24 +0100 Subject: Shards: Update database dependencies --- shard.lock | 6 +++--- shard.yml | 4 ++-- src/invidious/yt_backend/connection_pool.cr | 9 ++++++++- 3 files changed, 13 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/shard.lock b/shard.lock index 397bd8bc..1f609631 100644 --- a/shard.lock +++ b/shard.lock @@ -14,7 +14,7 @@ shards: db: git: https://github.com/crystal-lang/crystal-db.git - version: 0.10.1 + version: 0.13.1 exception_page: git: https://github.com/crystal-loot/exception_page.git @@ -30,7 +30,7 @@ shards: pg: git: https://github.com/will/crystal-pg.git - version: 0.24.0 + version: 0.28.0 protodec: git: https://github.com/iv-org/protodec.git @@ -46,5 +46,5 @@ shards: sqlite3: git: https://github.com/crystal-lang/crystal-sqlite3.git - version: 0.18.0 + version: 0.21.0 diff --git a/shard.yml b/shard.yml index 367f7c73..e0e34c0c 100644 --- a/shard.yml +++ b/shard.yml @@ -12,10 +12,10 @@ targets: dependencies: pg: github: will/crystal-pg - version: ~> 0.24.0 + version: ~> 0.28.0 sqlite3: github: crystal-lang/crystal-sqlite3 - version: ~> 0.18.0 + version: ~> 0.21.0 kemal: github: kemalcr/kemal version: ~> 1.1.2 diff --git a/src/invidious/yt_backend/connection_pool.cr b/src/invidious/yt_backend/connection_pool.cr index ca612083..14cc2d47 100644 --- a/src/invidious/yt_backend/connection_pool.cr +++ b/src/invidious/yt_backend/connection_pool.cr @@ -44,7 +44,14 @@ struct YoutubeConnectionPool end private def build_pool - DB::Pool(HTTP::Client).new(initial_pool_size: 0, max_pool_size: capacity, max_idle_pool_size: capacity, checkout_timeout: timeout) do + options = DB::Pool::Options.new( + initial_pool_size: 0, + max_pool_size: capacity, + max_idle_pool_size: capacity, + checkout_timeout: timeout + ) + + DB::Pool(HTTP::Client).new(options) do conn = HTTP::Client.new(url) conn.family = CONFIG.force_resolve conn.family = Socket::Family::INET if conn.family == Socket::Family::UNSPEC -- cgit v1.2.3 From c243d08afb8509f7a98cd7aa1b77d4f409a7a823 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Wed, 30 Oct 2024 13:38:13 -0400 Subject: refactor --- src/invidious/channels/videos.cr | 47 +++++++++++++++------------------------- 1 file changed, 18 insertions(+), 29 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index e29d80ed..bcdc8d8f 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -23,6 +23,13 @@ def produce_channel_content_continuation(ucid, content_type, page = 1, auto_gene else 15 # Fallback to "videos" end + sort_type_numerical = + case content_type + when "videos" then 3 + when "livestreams" then 5 + else 3 # Fallback to "videos" + end + if content_type == "livestreams" sort_by_numerical = case sort_by @@ -41,39 +48,21 @@ def produce_channel_content_continuation(ucid, content_type, page = 1, auto_gene end end - if content_type == "livestreams" - object_inner_1 = { - "110:embedded" => { - "3:embedded" => { - "#{content_type_numerical}:embedded" => { - "1:embedded" => { - "1:string" => object_inner_2_encoded, - }, - "2:embedded" => { - "1:string" => "00000000-0000-0000-0000-000000000000", - }, - "5:varint" => sort_by_numerical, + object_inner_1 = { + "110:embedded" => { + "3:embedded" => { + "#{content_type_numerical}:embedded" => { + "1:embedded" => { + "1:string" => object_inner_2_encoded, }, - }, - }, - } - else - object_inner_1 = { - "110:embedded" => { - "3:embedded" => { - "#{content_type_numerical}:embedded" => { - "1:embedded" => { - "1:string" => object_inner_2_encoded, - }, - "2:embedded" => { - "1:string" => "00000000-0000-0000-0000-000000000000", - }, - "3:varint" => sort_by_numerical, + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", }, + "#{sort_type_numerical}:varint" => sort_by_numerical, }, }, - } - end + }, + } object_inner_1_encoded = object_inner_1 .try { |i| Protodec::Any.cast_json(i) } -- cgit v1.2.3 From cdf93b29e6376ea0c023da825aeb9d83ec588873 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 31 Oct 2024 11:51:33 +0100 Subject: Routing: Remove deprecated /api/v1/channels/.../:ucid routes --- src/invidious/routing.cr | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index ba05da19..9f76f15f 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -243,17 +243,16 @@ module Invidious::Routing # Channels get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home + get "/api/v1/channels/:ucid/latest", {{namespace}}::Channels, :latest + get "/api/v1/channels/:ucid/videos", {{namespace}}::Channels, :videos get "/api/v1/channels/:ucid/shorts", {{namespace}}::Channels, :shorts get "/api/v1/channels/:ucid/streams", {{namespace}}::Channels, :streams get "/api/v1/channels/:ucid/podcasts", {{namespace}}::Channels, :podcasts get "/api/v1/channels/:ucid/releases", {{namespace}}::Channels, :releases - + get "/api/v1/channels/:ucid/playlists", {{namespace}}::Channels, :playlists + get "/api/v1/channels/:ucid/community", {{namespace}}::Channels, :community get "/api/v1/channels/:ucid/channels", {{namespace}}::Channels, :channels - - {% for route in {"videos", "latest", "playlists", "community", "search"} %} - get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} - get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} - {% end %} + get "/api/v1/channels/:ucid/search", {{namespace}}::Channels, :search # Posts get "/api/v1/post/:id", {{namespace}}::Channels, :post -- cgit v1.2.3 From 6da18ddc41a20cad06c736a28aef6064433e3bd5 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 31 Oct 2024 11:52:09 +0100 Subject: Routing: Also remove outdated comment about notification routes --- src/invidious/routing.cr | 5 ----- 1 file changed, 5 deletions(-) (limited to 'src') diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 9f76f15f..9009062f 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -270,11 +270,6 @@ module Invidious::Routing # Authenticated - # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr - # - # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences -- cgit v1.2.3 From 75c5881c553b8225f389db11733639ae62885c2f Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 31 Oct 2024 13:31:59 +0100 Subject: Locales: Add Bulgarian, Welsh and Lombard to the list --- src/invidious/helpers/i18n.cr | 15 +++++++++++++++ 1 file changed, 15 insertions(+) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 23a1aafc..1ba3ea61 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -1,8 +1,22 @@ +# Languages requiring a better level of translation (at least 20%) +# to be added to the list below: +# +# "af" => "", # Afrikaans +# "az" => "", # Azerbaijani +# "be" => "", # Belarusian +# "bn_BD" => "", # Bengali (Bangladesh) +# "ia" => "", # Interlingua +# "or" => "", # Odia +# "tk" => "", # Turkmen +# "tok => "", # Toki Pona +# LOCALES_LIST = { "ar" => "العربية", # Arabic + "bg" => "български", # Bulgarian "bn" => "বাংলা", # Bengali "ca" => "Català", # Catalan "cs" => "Čeština", # Czech + "cy" => "Cymraeg", # Welsh "da" => "Dansk", # Danish "de" => "Deutsch", # German "el" => "Ελληνικά", # Greek @@ -23,6 +37,7 @@ LOCALES_LIST = { "it" => "Italiano", # Italian "ja" => "日本語", # Japanese "ko" => "한국어", # Korean + "lmo" => "Lombard", # Lombard "lt" => "Lietuvių", # Lithuanian "nb-NO" => "Norsk bokmål", # Norwegian Bokmål "nl" => "Nederlands", # Dutch -- cgit v1.2.3 From ac6e796c732bb4be5a0fe6be9ba53ad49c49bd51 Mon Sep 17 00:00:00 2001 From: "Émilien (perso)" <4016501+unixfox@users.noreply.github.com> Date: Thu, 7 Nov 2024 14:04:43 +0100 Subject: checking the status code returned by youtube (#5052) * checking the status code returned by youtube * add documentation link * Update src/invidious/yt_backend/youtube_api.cr Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com> --------- Co-authored-by: syeopite <70992037+syeopite@users.noreply.github.com> --- src/invidious/yt_backend/youtube_api.cr | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index baa3cd92..e0a3181f 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -638,6 +638,11 @@ module YoutubeAPI # Send the POST request body = YT_POOL.client() do |client| client.post(url, headers: headers, body: data.to_json) do |response| + if response.status_code != 200 + raise InfoException.new("Error: non 200 status code. Youtube API returned \ + status code #{response.status_code}. See \ + https://docs.invidious.io/youtube-errors-explained/ for troubleshooting.") + end self._decompress(response.body_io, response.headers["Content-Encoding"]?) end end -- cgit v1.2.3 From cbc546f0320e4833927a654c26d384bb2e8a9f93 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Nov 2024 22:54:21 +0100 Subject: Channels: Add function to generate the new ctoken objects --- src/invidious/channels/videos.cr | 207 +++++++++++++++++++-------------------- 1 file changed, 103 insertions(+), 104 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index bcdc8d8f..7b3e3cfa 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -1,95 +1,3 @@ -def produce_channel_content_continuation(ucid, content_type, page = 1, auto_generated = nil, sort_by = "newest", v2 = false) - object_inner_2 = { - "2:0:embedded" => { - "1:0:varint" => 0_i64, - }, - "5:varint" => 50_i64, - "6:varint" => 1_i64, - "7:varint" => (page * 30).to_i64, - "9:varint" => 1_i64, - "10:varint" => 0_i64, - } - - object_inner_2_encoded = object_inner_2 - .try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - content_type_numerical = - case content_type - when "videos" then 15 - when "livestreams" then 14 - else 15 # Fallback to "videos" - end - - sort_type_numerical = - case content_type - when "videos" then 3 - when "livestreams" then 5 - else 3 # Fallback to "videos" - end - - if content_type == "livestreams" - sort_by_numerical = - case sort_by - when "newest" then 12_i64 - when "popular" then 14_i64 - when "oldest" then 13_i64 - else 12_i64 # Fallback to "newest" - end - else - sort_by_numerical = - case sort_by - when "newest" then 1_i64 - when "popular" then 2_i64 - when "oldest" then 4_i64 - else 1_i64 # Fallback to "newest" - end - end - - object_inner_1 = { - "110:embedded" => { - "3:embedded" => { - "#{content_type_numerical}:embedded" => { - "1:embedded" => { - "1:string" => object_inner_2_encoded, - }, - "2:embedded" => { - "1:string" => "00000000-0000-0000-0000-000000000000", - }, - "#{sort_type_numerical}:varint" => sort_by_numerical, - }, - }, - }, - } - - object_inner_1_encoded = object_inner_1 - .try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - object = { - "80226972:embedded" => { - "2:string" => ucid, - "3:string" => object_inner_1_encoded, - "35:string" => "browse-feed#{ucid}videos102", - }, - } - - continuation = object.try { |i| Protodec::Any.cast_json(i) } - .try { |i| Protodec::Any.from_json(i) } - .try { |i| Base64.urlsafe_encode(i) } - .try { |i| URI.encode_www_form(i) } - - return continuation -end - -def make_initial_content_ctoken(ucid, content_type, sort_by) : String - return produce_channel_content_continuation(ucid, content_type, sort_by: sort_by) -end - module Invidious::Channel::Tabs extend self @@ -118,7 +26,7 @@ module Invidious::Channel::Tabs end def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest") - continuation ||= make_initial_content_ctoken(ucid, "videos", sort_by) + continuation ||= make_videos_ctoken(ucid, sort_by) initial_data = YoutubeAPI.browse(continuation: continuation) return extract_items(initial_data, author, ucid) @@ -147,14 +55,10 @@ module Invidious::Channel::Tabs # Shorts # ------------------- - def get_shorts(channel : AboutChannel, continuation : String? = nil) - if continuation.nil? - # EgZzaG9ydHPyBgUKA5oBAA%3D%3D is the protobuf object to load "shorts" - # TODO: try to extract the continuation tokens that allows other sorting options - initial_data = YoutubeAPI.browse(channel.ucid, params: "EgZzaG9ydHPyBgUKA5oBAA%3D%3D") - else - initial_data = YoutubeAPI.browse(continuation: continuation) - end + def get_shorts(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") + continuation ||= make_shorts_ctoken(channel.ucid, sort_by) + initial_data = YoutubeAPI.browse(continuation: continuation) + return extract_items(initial_data, channel.author, channel.ucid) end @@ -162,9 +66,8 @@ module Invidious::Channel::Tabs # Livestreams # ------------------- - def get_livestreams(channel : AboutChannel, continuation : String? = nil, sort_by = "newest") - continuation ||= make_initial_content_ctoken(channel.ucid, "livestreams", sort_by) - + def get_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") + continuation ||= make_livestreams_ctoken(channel.ucid, sort_by) initial_data = YoutubeAPI.browse(continuation: continuation) return extract_items(initial_data, channel.author, channel.ucid) @@ -188,4 +91,100 @@ module Invidious::Channel::Tabs return items, next_continuation end + + # ------------------- + # C-tokens + # ------------------- + + private def sort_options_videos_short(sort_by : String) + case sort_by + when "newest" then return 4_i64 + when "popular" then return 2_i64 + when "oldest" then return 5_i64 + else return 4_i64 # Fallback to "newest" + end + end + + # Generate the initial "continuation token" to get the first page of the + # "videos" tab. The following page requires the ctoken provided in that + # first page, and so on. + private def make_videos_ctoken(ucid : String, sort_by = "newest") + object = { + "15:embedded" => { + "2:string" => "\n$00000000-0000-0000-0000-000000000000", + "4:varint" => sort_options_videos_short(sort_by), + }, + } + + return channel_ctoken_wrap(ucid, object) + end + + # Generate the initial "continuation token" to get the first page of the + # "shorts" tab. The following page requires the ctoken provided in that + # first page, and so on. + private def make_shorts_ctoken(ucid : String, sort_by = "newest") + object = { + "10:embedded" => { + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", + }, + "4:varint" => sort_options_videos_short(sort_by), + }, + } + + return channel_ctoken_wrap(ucid, object) + end + + # Generate the initial "continuation token" to get the first page of the + # "livestreams" tab. The following page requires the ctoken provided in that + # first page, and so on. + private def make_livestreams_ctoken(ucid : String, sort_by = "newest") + sort_by_numerical = + case sort_by + when "newest" then 12_i64 + when "popular" then 14_i64 + when "oldest" then 13_i64 + else 12_i64 # Fallback to "newest" + end + + object = { + "14:embedded" => { + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", + }, + "5:varint" => sort_by_numerical, + }, + } + + return channel_ctoken_wrap(ucid, object) + end + + # The protobuf structure common between videos/shorts/livestreams + private def channel_ctoken_wrap(ucid : String, object) + object_inner = { + "110:embedded" => { + "3:embedded" => object, + }, + } + + object_inner_encoded = object_inner + .try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + object = { + "80226972:embedded" => { + "2:string" => ucid, + "3:string" => object_inner_encoded, + }, + } + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation + end end -- cgit v1.2.3 From 82248fad024de5289011e2ae26d5c390d5084827 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 7 Nov 2024 23:00:18 +0100 Subject: Channels: Add sort options to shorts --- src/invidious/routes/channels.cr | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 952098e0..d4e9fa68 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -82,13 +82,12 @@ module Invidious::Routes::Channels end next_continuation = nil else - # TODO: support sort option for shorts - sort_by = "" - sort_options = [] of String + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" + sort_options = {"newest", "oldest", "popular"} # Fetch items and continuation token items, next_continuation = Channel::Tabs.get_shorts( - channel, continuation: continuation + channel, continuation: continuation, sort_by: sort_by ) end -- cgit v1.2.3 From 1a5047aad94454fd8a8d9623e17ee3782c68c3d0 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 8 Nov 2024 12:33:14 +0100 Subject: Extractors: Add support for lockupViewModel The 'lockupViewModel' structure is used in the channel "podcasts" tab --- src/invidious/yt_backend/extractors.cr | 76 ++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 4074de86..cb8331a5 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -467,9 +467,9 @@ private module Parsers # Parses an InnerTube richItemRenderer into a SearchVideo. # Returns nil when the given object isn't a RichItemRenderer # - # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used - # by the result page for hashtags and for the podcast tab on channels. - # It is located inside a continuationItems container for hashtags. + # A richItemRenderer seems to be a simple wrapper for a various other types, + # used on the hashtags result page and the channel podcast tab. It is located + # itself inside a richGridRenderer container. # module RichItemRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) @@ -482,6 +482,7 @@ private module Parsers child = VideoRendererParser.process(item_contents, author_fallback) child ||= ReelItemRendererParser.process(item_contents, author_fallback) child ||= PlaylistRendererParser.process(item_contents, author_fallback) + child ||= LockupViewModelParser.process(item_contents, author_fallback) return child end @@ -582,6 +583,75 @@ private module Parsers end end + # Parses an InnerTube lockupViewModel into a SearchPlaylist. + # Returns nil when the given object is not a lockupViewModel. + # + # This structure is present since November 2024 on the "podcasts" tab of the + # channel page. It is usually (always?) encapsulated in a richItemRenderer. + # + module LockupViewModelParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["lockupViewModel"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + playlist_id = item_contents["contentId"].as_s + + thumbnail_view_model = item_contents.dig( + "contentImage", "collectionThumbnailViewModel", + "primaryThumbnail", "thumbnailViewModel" + ) + + thumbnail = thumbnail_view_model.dig("image", "sources", 1, "url").as_s + + # This complicated sequences tries to extract the following data structure: + # "overlays": [{ + # "thumbnailOverlayBadgeViewModel": { + # "thumbnailBadges": [{ + # "thumbnailBadgeViewModel": { + # "text": "430 episodes", + # "badgeStyle": "THUMBNAIL_OVERLAY_BADGE_STYLE_DEFAULT" + # } + # }] + # } + # }] + video_count = thumbnail_view_model.dig("overlays").as_a + .compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a) + .flatten + .find(nil, &.dig?("thumbnailBadgeViewModel", "text").try &.as_s.ends_with?("episodes")) + .try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false) + + metadata = item_contents.dig("metadata", "lockupMetadataViewModel") + title = metadata.dig("title", "content").as_s + + # TODO: Retrieve "updated" info from metadata parts + # rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").as_a + # parts_text = rows.map(&.dig?("metadataParts", "text", "content").try &.as_s) + # One of these parts should contain a string like: "Updated 2 days ago" + + # TODO: Maybe add a button to access the first video of the playlist? + # item_contents.dig("rendererContext", "commandContext", "onTap", "innertubeCommand", "watchEndpoint") + # Available fields: "videoId", "playlistId", "params" + + return SearchPlaylist.new({ + title: title, + id: playlist_id, + author: author_fallback.name, + ucid: author_fallback.id, + video_count: video_count || -1, + videos: [] of SearchPlaylistVideo, + thumbnail: thumbnail, + author_verified: false, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + # Parses an InnerTube continuationItemRenderer into a Continuation. # Returns nil when the given object isn't a continuationItemRenderer. # -- cgit v1.2.3 From afc5b27d83d8b2b287842ed1ec43185135441d37 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 8 Nov 2024 13:32:44 +0100 Subject: Extractors: Add support for shortsLockupViewModel The 'shortsLockupViewModel' structure is used in the channel "shorts" tab --- src/invidious/yt_backend/extractors.cr | 58 ++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index cb8331a5..4416ef30 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -483,6 +483,7 @@ private module Parsers child ||= ReelItemRendererParser.process(item_contents, author_fallback) child ||= PlaylistRendererParser.process(item_contents, author_fallback) child ||= LockupViewModelParser.process(item_contents, author_fallback) + child ||= ShortsLockupViewModelParser.process(item_contents, author_fallback) return child end @@ -497,6 +498,9 @@ private module Parsers # reelItemRenderer items are used in the new (2022) channel layout, # in the "shorts" tab. # + # NOTE: As of 10/2024, it might have been fully replaced by shortsLockupViewModel + # TODO: Confirm that hypothesis + # module ReelItemRendererParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) if item_contents = item["reelItemRenderer"]? @@ -652,6 +656,60 @@ private module Parsers end end + # Parses an InnerTube shortsLockupViewModel into a SearchVideo. + # Returns nil when the given object is not a shortsLockupViewModel. + # + # This structure is present since around October 2024 on the "shorts" tab of + # the channel page and likely replaces the reelItemRenderer structure. It is + # usually (always?) encapsulated in a richItemRenderer. + # + module ShortsLockupViewModelParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item["shortsLockupViewModel"]? + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + # TODO: Maybe add support for "oardefault.jpg" thumbnails? + # thumbnail = item_contents.dig("thumbnail", "sources", 0, "url").as_s + # Gives: https://i.ytimg.com/vi/{video_id}/oardefault.jpg?... + + video_id = item_contents.dig( + "onTap", "innertubeCommand", "reelWatchEndpoint", "videoId" + ).as_s + + title = item_contents.dig("overlayMetadata", "primaryText", "content").as_s + + view_count = short_text_to_number( + item_contents.dig("overlayMetadata", "secondaryText", "content").as_s + ) + + # Approximate to one minute, as "shorts" generally don't exceed that. + # NOTE: The actual duration is not provided by Youtube anymore. + # TODO: Maybe use -1 as an error value and handle that on the frontend? + duration = 60_i32 + + SearchVideo.new({ + title: title, + id: video_id, + author: author_fallback.name, + ucid: author_fallback.id, + published: Time.unix(0), + views: view_count, + description_html: "", + length_seconds: duration, + premiere_timestamp: Time.unix(0), + author_verified: false, + badges: VideoBadges::None, + }) + end + + def self.parser_name + return {{@type.name}} + end + end + # Parses an InnerTube continuationItemRenderer into a Continuation. # Returns nil when the given object isn't a continuationItemRenderer. # -- cgit v1.2.3 From d27a5e7fae4a826b66950422ff8dfec4123dabf1 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 8 Nov 2024 13:33:46 +0100 Subject: Channels: Rename ctoken generator functions as requested --- src/invidious/channels/videos.cr | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index 7b3e3cfa..9572adf3 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -26,7 +26,7 @@ module Invidious::Channel::Tabs end def get_videos(author : String, ucid : String, *, continuation : String? = nil, sort_by = "newest") - continuation ||= make_videos_ctoken(ucid, sort_by) + continuation ||= make_initial_videos_ctoken(ucid, sort_by) initial_data = YoutubeAPI.browse(continuation: continuation) return extract_items(initial_data, author, ucid) @@ -56,7 +56,7 @@ module Invidious::Channel::Tabs # ------------------- def get_shorts(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") - continuation ||= make_shorts_ctoken(channel.ucid, sort_by) + continuation ||= make_initial_shorts_ctoken(channel.ucid, sort_by) initial_data = YoutubeAPI.browse(continuation: continuation) return extract_items(initial_data, channel.author, channel.ucid) @@ -67,7 +67,7 @@ module Invidious::Channel::Tabs # ------------------- def get_livestreams(channel : AboutChannel, *, continuation : String? = nil, sort_by = "newest") - continuation ||= make_livestreams_ctoken(channel.ucid, sort_by) + continuation ||= make_initial_livestreams_ctoken(channel.ucid, sort_by) initial_data = YoutubeAPI.browse(continuation: continuation) return extract_items(initial_data, channel.author, channel.ucid) @@ -108,7 +108,7 @@ module Invidious::Channel::Tabs # Generate the initial "continuation token" to get the first page of the # "videos" tab. The following page requires the ctoken provided in that # first page, and so on. - private def make_videos_ctoken(ucid : String, sort_by = "newest") + private def make_initial_videos_ctoken(ucid : String, sort_by = "newest") object = { "15:embedded" => { "2:string" => "\n$00000000-0000-0000-0000-000000000000", @@ -122,7 +122,7 @@ module Invidious::Channel::Tabs # Generate the initial "continuation token" to get the first page of the # "shorts" tab. The following page requires the ctoken provided in that # first page, and so on. - private def make_shorts_ctoken(ucid : String, sort_by = "newest") + private def make_initial_shorts_ctoken(ucid : String, sort_by = "newest") object = { "10:embedded" => { "2:embedded" => { @@ -138,7 +138,7 @@ module Invidious::Channel::Tabs # Generate the initial "continuation token" to get the first page of the # "livestreams" tab. The following page requires the ctoken provided in that # first page, and so on. - private def make_livestreams_ctoken(ucid : String, sort_by = "newest") + private def make_initial_livestreams_ctoken(ucid : String, sort_by = "newest") sort_by_numerical = case sort_by when "newest" then 12_i64 -- cgit v1.2.3 From 301aeffa780fca321793f8c2ef46844d613ce5c3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 8 Nov 2024 13:54:05 +0100 Subject: Channels: Multiple small fixes Fix the "newest" link not being bold when 'sort_by' uses the default value Show 60 videos per page, rather than 30 --- src/invidious/routes/channels.cr | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index d4e9fa68..7d634cbb 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -20,10 +20,11 @@ module Invidious::Routes::Channels sort_by = env.params.query["sort_by"]?.try &.downcase if channel.auto_generated + sort_by ||= "last" sort_options = {"last", "oldest", "newest"} items, next_continuation = fetch_channel_playlists( - channel.ucid, channel.author, continuation, (sort_by || "last") + channel.ucid, channel.author, continuation, sort_by ) items.uniq! do |item| @@ -49,9 +50,11 @@ module Invidious::Routes::Channels end next_continuation = nil else + sort_by ||= "newest" sort_options = {"newest", "oldest", "popular"} - items, next_continuation = Channel::Tabs.get_videos( - channel, continuation: continuation, sort_by: (sort_by || "newest") + + items, next_continuation = Channel::Tabs.get_60_videos( + channel, continuation: continuation, sort_by: sort_by ) end end -- cgit v1.2.3 From 6dd662a5b84b3deb9e19e365f8b480357f63a2e9 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 8 Nov 2024 17:25:23 +0100 Subject: Channels: lockupViewModel is also used in the "playlists" tab --- src/invidious/yt_backend/extractors.cr | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 4416ef30..2631b62a 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -21,6 +21,7 @@ private ITEM_PARSERS = { Parsers::ItemSectionRendererParser, Parsers::ContinuationItemRendererParser, Parsers::HashtagRendererParser, + Parsers::LockupViewModelParser, } private alias InitialData = Hash(String, JSON::Any) @@ -590,8 +591,9 @@ private module Parsers # Parses an InnerTube lockupViewModel into a SearchPlaylist. # Returns nil when the given object is not a lockupViewModel. # - # This structure is present since November 2024 on the "podcasts" tab of the - # channel page. It is usually (always?) encapsulated in a richItemRenderer. + # This structure is present since November 2024 on the "podcasts" and + # "playlists" tabs of the channel page. It is usually encapsulated in either + # a richItemRenderer or a richGridRenderer. # module LockupViewModelParser def self.process(item : JSON::Any, author_fallback : AuthorFallback) @@ -608,7 +610,7 @@ private module Parsers "primaryThumbnail", "thumbnailViewModel" ) - thumbnail = thumbnail_view_model.dig("image", "sources", 1, "url").as_s + thumbnail = thumbnail_view_model.dig("image", "sources", 0, "url").as_s # This complicated sequences tries to extract the following data structure: # "overlays": [{ @@ -621,10 +623,15 @@ private module Parsers # }] # } # }] + # + # NOTE: this simplistic `.to_i` conversion might not work on larger + # playlists and hasn't been tested. video_count = thumbnail_view_model.dig("overlays").as_a .compact_map(&.dig?("thumbnailOverlayBadgeViewModel", "thumbnailBadges").try &.as_a) .flatten - .find(nil, &.dig?("thumbnailBadgeViewModel", "text").try &.as_s.ends_with?("episodes")) + .find(nil, &.dig?("thumbnailBadgeViewModel", "text").try { |node| + {"episodes", "videos"}.any? { |str| node.as_s.ends_with?(str) } + }) .try &.dig("thumbnailBadgeViewModel", "text").as_s.to_i(strict: false) metadata = item_contents.dig("metadata", "lockupMetadataViewModel") -- cgit v1.2.3 From 2a19dbb1fee20e5438751c3bb387f8757f4c2238 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Fri, 8 Nov 2024 18:28:55 +0100 Subject: Channels: Use the same structure as in the other ctoken functions Change explanation, courtesy of iBicha: The \n is basically a decimal 10, which is 1010 binary. That is a field number 1, and a wire type 2 (length-delimited). Then the $ is a decimal 36, which is exactly the length of 00000000-0000-0000-0000-000000000000. So both objects end up being encoded into the same data. --- src/invidious/channels/videos.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/channels/videos.cr b/src/invidious/channels/videos.cr index 9572adf3..96400f47 100644 --- a/src/invidious/channels/videos.cr +++ b/src/invidious/channels/videos.cr @@ -111,7 +111,9 @@ module Invidious::Channel::Tabs private def make_initial_videos_ctoken(ucid : String, sort_by = "newest") object = { "15:embedded" => { - "2:string" => "\n$00000000-0000-0000-0000-000000000000", + "2:embedded" => { + "1:string" => "00000000-0000-0000-0000-000000000000", + }, "4:varint" => sort_options_videos_short(sort_by), }, } -- cgit v1.2.3 From 09ccea1d31366e5f6ce1c0b3e13f3a8d84428184 Mon Sep 17 00:00:00 2001 From: "Émilien (perso)" <4016501+unixfox@users.noreply.github.com> Date: Fri, 8 Nov 2024 22:01:23 +0100 Subject: remove usage of TVHTML5_SIMPLY_EMBEDDED_PLAYER --- src/invidious/videos/parser.cr | 8 -------- 1 file changed, 8 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index fb8935d9..8dab5881 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -124,14 +124,6 @@ def extract_video_info(video_id : String) new_player_response = try_fetch_streaming_data(video_id, client_config) end - # Last hope - # Only trigger if reason found or didn't work wth Android client. - # TvHtml5ScreenEmbed now requires sig helper for it to work but doesn't work with po_token. - if reason && CONFIG.po_token.nil? - client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed - new_player_response = try_fetch_streaming_data(video_id, client_config) - end - # Replace player response and reset reason if !new_player_response.nil? # Preserve captions & storyboard data before replacement -- cgit v1.2.3 From b9ad9bd72331e8a568bd11813cb0169bd9c2a831 Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 8 Nov 2024 21:47:52 +0100 Subject: use WEB when po_token + android test suite when no po_token --- src/invidious/videos/parser.cr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index fb8935d9..b2744120 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -53,9 +53,9 @@ end def extract_video_info(video_id : String) # Init client config for the API client_config = YoutubeAPI::ClientConfig.new - # Use the WEB_CREATOR when po_token is configured because it fully only works on this client + # Use the WEB when po_token is configured if CONFIG.po_token - client_config.client_type = YoutubeAPI::ClientType::WebCreator + client_config.client_type = YoutubeAPI::ClientType::Web end # Fetch data from the player endpoint @@ -113,8 +113,8 @@ def extract_video_info(video_id : String) new_player_response = try_fetch_streaming_data(video_id, client_config) end - # Don't use Android client if po_token is passed because po_token doesn't - # work for Android client. + # Don't use Android test suite client if po_token is passed because po_token doesn't + # work for Android test suite client. if reason.nil? && CONFIG.po_token.nil? # Fetch the video streams using an Android client in order to get the # decrypted URLs and maybe fix throttling issues (#2194). See the -- cgit v1.2.3 From 82b1506cccc85ed4f3979d674a2754c35e0194fc Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 8 Nov 2024 21:56:24 +0100 Subject: remove usage of WebEmbeddedPlayer --- src/invidious/videos/parser.cr | 7 ------- 1 file changed, 7 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index b2744120..1f229df0 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -106,13 +106,6 @@ def extract_video_info(video_id : String) new_player_response = nil - # Second try in case WEB_CREATOR doesn't work with po_token. - # Only trigger if reason found and po_token configured. - if reason && CONFIG.po_token - client_config.client_type = YoutubeAPI::ClientType::WebEmbeddedPlayer - new_player_response = try_fetch_streaming_data(video_id, client_config) - end - # Don't use Android test suite client if po_token is passed because po_token doesn't # work for Android test suite client. if reason.nil? && CONFIG.po_token.nil? -- cgit v1.2.3 From f3e93ca83d21ab7e766d931c3985cf291e96ad3e Mon Sep 17 00:00:00 2001 From: Emilien <4016501+unixfox@users.noreply.github.com> Date: Fri, 8 Nov 2024 21:56:48 +0100 Subject: revert back to www.youtube.com when client_config.screen embed --- src/invidious/yt_backend/youtube_api.cr | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index e0a3181f..8f5aa61d 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -300,9 +300,8 @@ module YoutubeAPI end if client_config.screen == "EMBED" - # embedUrl https://www.google.com allow loading almost all video that are configured not embeddable client_context["thirdParty"] = { - "embedUrl" => "https://www.google.com/", + "embedUrl" => "https://www.youtube.com/embed/#{video_id}", } of String => String | Int64 end -- cgit v1.2.3 From 0f8f32bca8739dfc05edc70ac634bfebe763d927 Mon Sep 17 00:00:00 2001 From: "Émilien (perso)" <4016501+unixfox@users.noreply.github.com> Date: Fri, 8 Nov 2024 23:33:19 +0100 Subject: remove explicit usage of WEB --- src/invidious/videos/parser.cr | 4 ---- 1 file changed, 4 deletions(-) (limited to 'src') diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 1f229df0..65ba8a3d 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -53,10 +53,6 @@ end def extract_video_info(video_id : String) # Init client config for the API client_config = YoutubeAPI::ClientConfig.new - # Use the WEB when po_token is configured - if CONFIG.po_token - client_config.client_type = YoutubeAPI::ClientType::Web - end # Fetch data from the player endpoint player_response = YoutubeAPI.player(video_id: video_id, params: "2AMB", client_config: client_config) -- cgit v1.2.3 From d2123b46829e57515e281ffd98b75dac3de6f379 Mon Sep 17 00:00:00 2001 From: Brahim Hadriche Date: Sat, 9 Nov 2024 17:49:06 -0500 Subject: Sort channel shorts API --- src/invidious/routes/api/v1/channels.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 2da76134..588bbc2a 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -197,6 +197,7 @@ module Invidious::Routes::API::V1::Channels get_channel() # Retrieve continuation from URL parameters + sort_by = env.params.query["sort_by"]?.try &.downcase || "newest" continuation = env.params.query["continuation"]? if channel.is_age_gated @@ -211,7 +212,7 @@ module Invidious::Routes::API::V1::Channels else begin videos, next_continuation = Channel::Tabs.get_shorts( - channel, continuation: continuation + channel, continuation: continuation, sort_by: sort_by ) rescue ex return error_json(500, ex) -- cgit v1.2.3

    + _helpers.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    community.js diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index df3112db..641cbe2c 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -97,6 +97,7 @@ }.to_pretty_json %> + <% end %> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index bd908dd6..79decbe6 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -157,6 +157,7 @@
    + <% if env.get? "user" %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 8b6eb903..e6a14d0f 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -165,6 +165,7 @@ we're going to need to do it here in order to allow for translations. }.to_pretty_json %> + <% end %> <% end %> @@ -303,4 +304,5 @@ we're going to need to do it here in order to allow for translations. <% end %> + -- cgit v1.2.3 From 81ca205caa5e97767a48ef559dbef2d2330bee8e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 7 May 2022 15:34:56 +0200 Subject: Fix download of captions --- src/invidious/routes/watch.cr | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 867ffa6a..75475430 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -308,25 +308,26 @@ module Invidious::Routes::Watch extension = download_widget["ext"].as_s filename = "#{video_id}-#{title}.#{extension}" - # Pass form parameters as URL parameters for the handlers of both - # /latest_version and /api/v1/captions. This avoids an un-necessary - # redirect and duplicated (and hazardous) sanity checks. - env.params.query["id"] = video_id - env.params.query["title"] = filename - - # Delete the useless ones + # Delete the now useless URL parameters env.params.body.delete("id") env.params.body.delete("title") env.params.body.delete("download_widget") + # Pass form parameters as URL parameters for the handlers of both + # /latest_version and /api/v1/captions. This avoids an un-necessary + # redirect and duplicated (and hazardous) sanity checks. if label = download_widget["label"]? # URL params specific to /api/v1/captions/:id - env.params.query["label"] = URI.encode_www_form(label.as_s, space_to_plus: false) + env.params.url["id"] = video_id + env.params.query["title"] = filename + env.params.query["label"] = URI.decode_www_form(label.as_s) return Invidious::Routes::API::V1::Videos.captions(env) elsif itag = download_widget["itag"]?.try &.as_i # URL params specific to /latest_version + env.params.query["id"] = video_id env.params.query["itag"] = itag.to_s + env.params.query["title"] = filename env.params.query["local"] = "true" return Invidious::Routes::VideoPlayback.latest_version(env) -- cgit v1.2.3 From 125997f45f152b8cbe69de78cafce54a5d7064f7 Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Wed, 11 May 2022 10:22:39 +0200 Subject: Remove puts statements in config.cr --- src/invidious/config.cr | 5 ----- 1 file changed, 5 deletions(-) (limited to 'src') diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 93c4c0f7..a077c7fd 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -161,16 +161,13 @@ class Config {% env_id = "INVIDIOUS_#{ivar.id.upcase}" %} if ENV.has_key?({{env_id}}) - # puts %(Config.{{ivar.id}} : Loading from env var {{env_id}}) env_value = ENV.fetch({{env_id}}) success = false # Use YAML converter if specified {% ann = ivar.annotation(::YAML::Field) %} {% if ann && ann[:converter] %} - puts %(Config.{{ivar.id}} : Parsing "#{env_value}" as {{ivar.type}} with {{ann[:converter]}} converter) config.{{ivar.id}} = {{ann[:converter]}}.from_yaml(YAML::ParseContext.new, YAML::Nodes.parse(ENV.fetch({{env_id}})).nodes[0]) - puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}}) success = true # Use regular YAML parser otherwise @@ -181,9 +178,7 @@ class Config {{ivar_types}}.each do |ivar_type| if !success begin - # puts %(Config.{{ivar.id}} : Trying to parse "#{env_value}" as #{ivar_type}) config.{{ivar.id}} = ivar_type.from_yaml(env_value) - puts %(Config.{{ivar.id}} : Set to #{config.{{ivar.id}}} (#{ivar_type})) success = true rescue # nop -- cgit v1.2.3 From 1097648f0a675215594f52c0e1b1f97975a07f39 Mon Sep 17 00:00:00 2001 From: meow Date: Tue, 17 May 2022 10:09:01 +0300 Subject: Fix HTML validation. This is how browser really split tags --- src/invidious/views/watch.ecr | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index e6a14d0f..f2d8ba03 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -279,24 +279,24 @@ we're going to need to do it here in order to allow for translations. <% end %>

    <%= rv["title"] %>

    -
    -
    - <% if rv["ucid"]? %> - "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> - <% else %> - <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> - <% end %> -
    - -
    - <%= - views = rv["view_count"]?.try &.to_i? - views ||= rv["view_count_short"]?.try { |x| short_text_to_number(x) } - translate_count(locale, "generic_views_count", views || 0, NumberFormatting::Short) - %> -
    -
    +
    +
    + <% if rv["ucid"]? %> + "><%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> + <% else %> + <%= rv["author"]? %><% if rv["author_verified"]? == "true" %> <% end %> + <% end %> +
    + +
    + <%= + views = rv["view_count"]?.try &.to_i? + views ||= rv["view_count_short"]?.try { |x| short_text_to_number(x) } + translate_count(locale, "generic_views_count", views || 0, NumberFormatting::Short) + %> +
    +
    <% end %> <% end %> -- cgit v1.2.3 From c9594d46afb1f4a1d8fd75be5ef98309a92c2761 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Sun, 13 Mar 2022 22:46:45 +0100 Subject: Add links redirect inside channel description --- src/invidious/helpers/utils.cr | 8 ++++++++ src/invidious/routes/channels.cr | 6 ++++-- src/invidious/views/channel.ecr | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 8ae5034a..4f2df699 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -391,3 +391,11 @@ def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = " end return str end + +def make_html_with_links(baseText : String) : String + returnValue = baseText.dup + returnValue.scan(/https?:\/\/[^ \n]*/).each do |match| + returnValue = returnValue.sub(match[0], "#{match[0]}") + end + return returnValue +end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index cd2e3323..88c394c2 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -10,7 +10,7 @@ module Invidious::Routes::Channels if !data.is_a?(Tuple) return data end - locale, user, subscriptions, continuation, ucid, channel = data + locale, user, subscriptions, continuation, ucid, channel, description = data page = env.params.query["page"]?.try &.to_i? page ||= 1 @@ -201,6 +201,8 @@ module Invidious::Routes::Channels return error_template(500, ex) end - return {locale, user, subscriptions, continuation, ucid, channel} + description = make_html_with_links(channel.description_html) + + return {locale, user, subscriptions, continuation, ucid, channel, description} end end diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index 92f81ee4..b8a14afa 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -32,7 +32,7 @@
    -

    <%= channel.description_html %>

    +

    <%= description %>

    -- cgit v1.2.3 From 137534f90126bd786742f9f90dddd2cffe8f0965 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 18 May 2022 23:36:50 +0200 Subject: Fix for #3096 --- src/invidious/comments.cr | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 1f8de657..1237db5d 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -567,6 +567,10 @@ end def content_to_comment_html(content, video_id : String? = "") html_array = content.map do |run| + # Sometimes, there is an empty element. + # See: https://github.com/iv-org/invidious/issues/3096 + next if run.as_h.empty? + text = HTML.escape(run["text"].as_s) if run["navigationEndpoint"]? -- cgit v1.2.3 From 28efeaa4f2cd3c3d13e0d865dc50a2ec0be8e054 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Mon, 14 Mar 2022 22:37:22 +0100 Subject: Update management of channel description Follow this comment : https://github.com/iv-org/invidious/pull/2968#issuecomment-1066428317 --- src/invidious/channels/about.cr | 20 ++++++++++++++++---- src/invidious/comments.cr | 26 ++++++++++++++++++++++++++ src/invidious/helpers/utils.cr | 8 -------- src/invidious/routes/channels.cr | 6 ++---- src/invidious/views/channel.ecr | 2 +- 5 files changed, 45 insertions(+), 17 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index d48fd1fb..da71e9a8 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -6,6 +6,7 @@ record AboutChannel, author_url : String, author_thumbnail : String, banner : String?, + description : String, description_html : String, total_views : Int64, sub_count : Int32, @@ -52,8 +53,7 @@ def get_about_info(ucid, locale) : AboutChannel banners = initdata["header"]["interactiveTabbedHeaderRenderer"]?.try &.["banner"]?.try &.["thumbnails"]? banner = banners.try &.[-1]?.try &.["url"].as_s? - description = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"]["simpleText"].as_s - description_html = HTML.escape(description) + description_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"] is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) @@ -75,13 +75,24 @@ def get_about_info(ucid, locale) : AboutChannel author_verified_badge = initdata["header"].dig?("c4TabbedHeaderRenderer", "badges", 0, "metadataBadgeRenderer", "tooltip") author_verified = (author_verified_badge && author_verified_badge == "Verified") - description = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]?.try &.as_s? || "" - description_html = HTML.escape(description) + description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]? is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) end + description = !description_node.nil? ? description_node.as_s : "" + description_html = HTML.escape(description) + if !description_node.nil? + if description_node.as_h?.nil? + description_node = text_to_parsed_content(description_node.as_s) + end + description_html = parse_content(description_node) + if description_html == "" && description != "" + description_html = HTML.escape(description) + end + end + total_views = 0_i64 joined = Time.unix(0) @@ -125,6 +136,7 @@ def get_about_info(ucid, locale) : AboutChannel author_url: author_url, author_thumbnail: author_thumbnail, banner: banner, + description: description, description_html: description_html, total_views: total_views, sub_count: sub_count, diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 1f8de657..c1bcc0a6 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -560,6 +560,32 @@ def fill_links(html, scheme, host) return html.to_xml(options: XML::SaveOptions::NO_DECL) end +def text_to_parsed_content(text : String) : JSON::Any + nodes = [] of JSON::Any + text.split('\n').each do |line| + currentNodes = [] of JSON::Any + initialNode = {"text" => line} + currentNodes << (JSON.parse(initialNode.to_json)) + line.scan(/https?:\/\/[^ ]*/).each do |uriMatch| + lastNode = currentNodes[currentNodes.size - 1].as_h + splittedLastNode = lastNode["text"].as_s.split(uriMatch[0]) + lastNode["text"] = JSON.parse(splittedLastNode[0].to_json) + currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) + currentNode = {"text" => uriMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => uriMatch[0]}}} + currentNodes << (JSON.parse(currentNode.to_json)) + afterNode = {"text" => splittedLastNode.size > 0 ? splittedLastNode[1] : ""} + currentNodes << (JSON.parse(afterNode.to_json)) + end + lastNode = currentNodes[currentNodes.size - 1].as_h + lastNode["text"] = JSON.parse("#{currentNodes[currentNodes.size - 1]["text"]}\n".to_json) + currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) + currentNodes.each do |node| + nodes << (node) + end + end + return JSON.parse({"runs" => nodes}.to_json) +end + def parse_content(content : JSON::Any, video_id : String? = "") : String content["simpleText"]?.try &.as_s.rchop('\ufeff').try { |b| HTML.escape(b) }.to_s || content["runs"]?.try &.as_a.try { |r| content_to_comment_html(r, video_id).try &.to_s.gsub("\n", "
    ") } || "" diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 4f2df699..8ae5034a 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -391,11 +391,3 @@ def reduce_uri(uri : URI | String, max_length : Int32 = 50, suffix : String = " end return str end - -def make_html_with_links(baseText : String) : String - returnValue = baseText.dup - returnValue.scan(/https?:\/\/[^ \n]*/).each do |match| - returnValue = returnValue.sub(match[0], "#{match[0]}") - end - return returnValue -end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index 88c394c2..cd2e3323 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -10,7 +10,7 @@ module Invidious::Routes::Channels if !data.is_a?(Tuple) return data end - locale, user, subscriptions, continuation, ucid, channel, description = data + locale, user, subscriptions, continuation, ucid, channel = data page = env.params.query["page"]?.try &.to_i? page ||= 1 @@ -201,8 +201,6 @@ module Invidious::Routes::Channels return error_template(500, ex) end - description = make_html_with_links(channel.description_html) - - return {locale, user, subscriptions, continuation, ucid, channel, description} + return {locale, user, subscriptions, continuation, ucid, channel} end end diff --git a/src/invidious/views/channel.ecr b/src/invidious/views/channel.ecr index b8a14afa..92f81ee4 100644 --- a/src/invidious/views/channel.ecr +++ b/src/invidious/views/channel.ecr @@ -32,7 +32,7 @@
    -

    <%= description %>

    +

    <%= channel.description_html %>

    -- cgit v1.2.3 From 2e195575a62886d2492e9c505d7bc283b7f60097 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Mon, 18 Apr 2022 17:20:47 +0200 Subject: Rename uriMatch to urlMatch inside comments.cr This refactor update text_to_parsed_content method --- src/invidious/comments.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index c1bcc0a6..199e4be3 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -566,12 +566,12 @@ def text_to_parsed_content(text : String) : JSON::Any currentNodes = [] of JSON::Any initialNode = {"text" => line} currentNodes << (JSON.parse(initialNode.to_json)) - line.scan(/https?:\/\/[^ ]*/).each do |uriMatch| + line.scan(/https?:\/\/[^ ]*/).each do |urlMatch| lastNode = currentNodes[currentNodes.size - 1].as_h - splittedLastNode = lastNode["text"].as_s.split(uriMatch[0]) + splittedLastNode = lastNode["text"].as_s.split(urlMatch[0]) lastNode["text"] = JSON.parse(splittedLastNode[0].to_json) currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) - currentNode = {"text" => uriMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => uriMatch[0]}}} + currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}} currentNodes << (JSON.parse(currentNode.to_json)) afterNode = {"text" => splittedLastNode.size > 0 ? splittedLastNode[1] : ""} currentNodes << (JSON.parse(afterNode.to_json)) -- cgit v1.2.3 From d8fb4f0a87f9fad3c32dd8528b789008945369a8 Mon Sep 17 00:00:00 2001 From: "Féry Mathieu (Mathius)" Date: Mon, 18 Apr 2022 17:29:04 +0200 Subject: Update text_to_parsed_content for add docs Follow this comment : https://github.com/iv-org/invidious/pull/2968#discussion_r851808433 --- src/invidious/comments.cr | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index 199e4be3..83074098 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -562,23 +562,39 @@ end def text_to_parsed_content(text : String) : JSON::Any nodes = [] of JSON::Any + # For each line convert line to array of nodes text.split('\n').each do |line| + # In first case line is just a simple node before + # check patterns inside line + # { 'text': line } currentNodes = [] of JSON::Any initialNode = {"text" => line} currentNodes << (JSON.parse(initialNode.to_json)) + + # For each match with url pattern, get last node and preserve + # last node before create new node with url information + # { 'text': match, 'navigationEndpoint': { 'urlEndpoint' : 'url': match } } line.scan(/https?:\/\/[^ ]*/).each do |urlMatch| + # Retrieve last node and update node without match lastNode = currentNodes[currentNodes.size - 1].as_h splittedLastNode = lastNode["text"].as_s.split(urlMatch[0]) lastNode["text"] = JSON.parse(splittedLastNode[0].to_json) currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) + # Create new node with match and navigation infos currentNode = {"text" => urlMatch[0], "navigationEndpoint" => {"urlEndpoint" => {"url" => urlMatch[0]}}} currentNodes << (JSON.parse(currentNode.to_json)) + # If text remain after match create new simple node with text after match afterNode = {"text" => splittedLastNode.size > 0 ? splittedLastNode[1] : ""} currentNodes << (JSON.parse(afterNode.to_json)) end + + # After processing of matches inside line + # Add \n at end of last node for preserve carriage return lastNode = currentNodes[currentNodes.size - 1].as_h lastNode["text"] = JSON.parse("#{currentNodes[currentNodes.size - 1]["text"]}\n".to_json) currentNodes[currentNodes.size - 1] = JSON.parse(lastNode.to_json) + + # Finally add final nodes to nodes returned currentNodes.each do |node| nodes << (node) end -- cgit v1.2.3 From 32be37355248c1e4723643b513c6f1175e88dc36 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 19 May 2022 23:16:51 +0200 Subject: Invert title & video ID in downloaded file name Fixes a regression of #2922 Issue reported by email --- src/invidious/routes/watch.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 75475430..7280de4f 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -306,7 +306,7 @@ module Invidious::Routes::Watch download_widget = JSON.parse(selection) extension = download_widget["ext"].as_s - filename = "#{video_id}-#{title}.#{extension}" + filename = "#{title}-#{video_id}.#{extension}" # Delete the now useless URL parameters env.params.body.delete("id") -- cgit v1.2.3 From 46891437e9562e3ed2536a41ea6c0bf838a24a3b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 21 May 2022 18:39:49 +0200 Subject: Add Estonian to i18n.cr --- src/invidious/helpers/i18n.cr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious/helpers/i18n.cr b/src/invidious/helpers/i18n.cr index 9d3c4e8b..fd86594c 100644 --- a/src/invidious/helpers/i18n.cr +++ b/src/invidious/helpers/i18n.cr @@ -10,6 +10,7 @@ LOCALES_LIST = { "en-US" => "English", # English "eo" => "Esperanto", # Esperanto "es" => "Español", # Spanish + "et" => "Eesti keel", # Estonian "fa" => "فارسی", # Persian "fi" => "Suomi", # Finnish "fr" => "Français", # French -- cgit v1.2.3 From ad37db4c820064d08e72014af339c7d789067937 Mon Sep 17 00:00:00 2001 From: DoodlesEpic Date: Tue, 24 May 2022 20:34:36 -0300 Subject: Fix document is empty error on yt kids video when reddit comments are enabled --- src/invidious/comments.cr | 12 ++++++++++++ 1 file changed, 12 insertions(+) (limited to 'src') diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index d8496978..1aa14935 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -500,6 +500,12 @@ def template_reddit_comments(root, locale) end def replace_links(html) + # Check if the document is empty + # Prevents edge-case bug with Reddit comments, see issue #3115 + if html.nil? || html.empty? + return html + end + html = XML.parse_html(html) html.xpath_nodes(%q(//a)).each do |anchor| @@ -541,6 +547,12 @@ def replace_links(html) end def fill_links(html, scheme, host) + # Check if the document is empty + # Prevents edge-case bug with Reddit comments, see issue #3115 + if html.nil? || html.empty? + return html + end + html = XML.parse_html(html) html.xpath_nodes("//a").each do |match| -- cgit v1.2.3 From c201ea53ba4b82195d9b3cd7dd939b93802d7a12 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Fri, 27 May 2022 13:36:13 +0000 Subject: Add 404 status code on all possible endpoints --- src/invidious/channels/about.cr | 7 ++++++- src/invidious/channels/community.cr | 8 ++++++-- src/invidious/comments.cr | 4 ++-- src/invidious/exceptions.cr | 4 ++++ src/invidious/playlists.cr | 2 +- src/invidious/routes/api/manifest.cr | 2 ++ src/invidious/routes/api/v1/authenticated.cr | 2 ++ src/invidious/routes/api/v1/channels.cr | 6 ++++++ src/invidious/routes/api/v1/videos.cr | 8 ++++++++ src/invidious/routes/channels.cr | 9 +++++++-- src/invidious/routes/embed.cr | 6 ++++++ src/invidious/routes/feeds.cr | 2 ++ src/invidious/routes/playlists.cr | 14 +++++++++++++- src/invidious/routes/video_playback.cr | 8 +++++++- src/invidious/routes/watch.cr | 3 +++ src/invidious/videos.cr | 6 +++++- 16 files changed, 80 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index da71e9a8..31b19bbe 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -31,7 +31,12 @@ def get_about_info(ucid, locale) : AboutChannel end if initdata.dig?("alerts", 0, "alertRenderer", "type") == "ERROR" - raise InfoException.new(initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s) + error_message = initdata["alerts"][0]["alertRenderer"]["text"]["simpleText"].as_s + if error_message == "This channel does not exist." + raise NotFoundException.new(error_message) + else + raise InfoException.new(error_message) + end end if browse_endpoint = initdata["onResponseReceivedActions"]?.try &.[0]?.try &.["navigateAction"]?.try &.["endpoint"]?.try &.["browseEndpoint"]? diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 4701ecbd..ebef0edb 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -6,7 +6,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) end if response.status_code != 200 - raise InfoException.new("This channel does not exist.") + raise NotFoundException.new("This channel does not exist.") end ucid = response.body.match(/https:\/\/www.youtube.com\/channel\/(?UC[a-zA-Z0-9_-]{22})/).not_nil!["ucid"] @@ -49,7 +49,11 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) error_message = (message["text"]["simpleText"]? || message["text"]["runs"]?.try &.[0]?.try &.["text"]?) .try &.as_s || "" - raise InfoException.new(error_message) + if error_message == "This channel does not exist." + raise NotFoundException.new(error_message) + else + raise InfoException.new(error_message) + end end response = JSON.build do |json| diff --git a/src/invidious/comments.cr b/src/invidious/comments.cr index d8496978..f2e63265 100644 --- a/src/invidious/comments.cr +++ b/src/invidious/comments.cr @@ -95,7 +95,7 @@ def fetch_youtube_comments(id, cursor, format, locale, thin_mode, region, sort_b contents = body["contents"]? header = body["header"]? else - raise InfoException.new("Could not fetch comments") + raise NotFoundException.new("Comments not found.") end if !contents @@ -290,7 +290,7 @@ def fetch_reddit_comments(id, sort_by = "confidence") thread = result[0].data.as(RedditListing).children[0].data.as(RedditLink) else - raise InfoException.new("Could not fetch comments") + raise NotFoundException.new("Comments not found.") end client.close diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index bfaa3fd5..1706ba6a 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -18,3 +18,7 @@ class BrokenTubeException < Exception return "Missing JSON element \"#{@element}\"" end end + +# Exception used to hold the bogus UCID during a channel search. +class NotFoundException < InfoException +end diff --git a/src/invidious/playlists.cr b/src/invidious/playlists.cr index aefa34cc..c4eb7507 100644 --- a/src/invidious/playlists.cr +++ b/src/invidious/playlists.cr @@ -317,7 +317,7 @@ def get_playlist(plid : String) if playlist = Invidious::Database::Playlists.select(id: plid) return playlist else - raise InfoException.new("Playlist does not exist.") + raise NotFoundException.new("Playlist does not exist.") end else return fetch_playlist(plid) diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index 8bc36946..f8766b66 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -16,6 +16,8 @@ module Invidious::Routes::API::Manifest video = get_video(id, region: region) rescue ex : VideoRedirect return env.redirect env.request.resource.gsub(id, ex.video_id) + rescue ex : NotFoundException + haltf env, status_code: 404 rescue ex haltf env, status_code: 403 end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index b559a01a..1f5ad8ef 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -237,6 +237,8 @@ module Invidious::Routes::API::V1::Authenticated begin video = get_video(video_id) + rescue ex : NotFoundException + return error_json(404, ex) rescue ex return error_json(500, ex) end diff --git a/src/invidious/routes/api/v1/channels.cr b/src/invidious/routes/api/v1/channels.cr index 8650976d..6b81c546 100644 --- a/src/invidious/routes/api/v1/channels.cr +++ b/src/invidious/routes/api/v1/channels.cr @@ -13,6 +13,8 @@ module Invidious::Routes::API::V1::Channels rescue ex : ChannelRedirect env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex : NotFoundException + return error_json(404, ex) rescue ex return error_json(500, ex) end @@ -170,6 +172,8 @@ module Invidious::Routes::API::V1::Channels rescue ex : ChannelRedirect env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex : NotFoundException + return error_json(404, ex) rescue ex return error_json(500, ex) end @@ -205,6 +209,8 @@ module Invidious::Routes::API::V1::Channels rescue ex : ChannelRedirect env.response.headers["Location"] = env.request.resource.gsub(ucid, ex.channel_id) return error_json(302, "Channel is unavailable", {"authorId" => ex.channel_id}) + rescue ex : NotFoundException + return error_json(404, ex) rescue ex return error_json(500, ex) end diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index a9f891f5..1b7b4fa7 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -12,6 +12,8 @@ module Invidious::Routes::API::V1::Videos rescue ex : VideoRedirect env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) + rescue ex : NotFoundException + return error_json(404, ex) rescue ex return error_json(500, ex) end @@ -42,6 +44,8 @@ module Invidious::Routes::API::V1::Videos rescue ex : VideoRedirect env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) + rescue ex : NotFoundException + haltf env, 404 rescue ex haltf env, 500 end @@ -167,6 +171,8 @@ module Invidious::Routes::API::V1::Videos rescue ex : VideoRedirect env.response.headers["Location"] = env.request.resource.gsub(id, ex.video_id) return error_json(302, "Video is unavailable", {"videoId" => ex.video_id}) + rescue ex : NotFoundException + haltf env, 404 rescue ex haltf env, 500 end @@ -324,6 +330,8 @@ module Invidious::Routes::API::V1::Videos begin comments = fetch_youtube_comments(id, continuation, format, locale, thin_mode, region, sort_by: sort_by) + rescue ex : NotFoundException + return error_json(404, ex) rescue ex return error_json(500, ex) end diff --git a/src/invidious/routes/channels.cr b/src/invidious/routes/channels.cr index cd2e3323..c6e02cbd 100644 --- a/src/invidious/routes/channels.cr +++ b/src/invidious/routes/channels.cr @@ -85,6 +85,9 @@ module Invidious::Routes::Channels rescue ex : InfoException env.response.status_code = 500 error_message = ex.message + rescue ex : NotFoundException + env.response.status_code = 404 + error_message = ex.message rescue ex return error_template(500, ex) end @@ -118,7 +121,7 @@ module Invidious::Routes::Channels resolved_url = YoutubeAPI.resolve_url("https://youtube.com#{env.request.path}#{yt_url_params.size > 0 ? "?#{yt_url_params}" : ""}") ucid = resolved_url["endpoint"]["browseEndpoint"]["browseId"] rescue ex : InfoException | KeyError - raise InfoException.new(translate(locale, "This channel does not exist.")) + return error_template(404, translate(locale, "This channel does not exist.")) end selected_tab = env.request.path.split("/")[-1] @@ -141,7 +144,7 @@ module Invidious::Routes::Channels user = env.params.query["user"]? if !user - raise InfoException.new("This channel does not exist.") + return error_template(404, "This channel does not exist.") else env.redirect "/user/#{user}#{uri_params}" end @@ -197,6 +200,8 @@ module Invidious::Routes::Channels channel = get_about_info(ucid, locale) rescue ex : ChannelRedirect return env.redirect env.request.resource.gsub(ucid, ex.channel_id) + rescue ex : NotFoundException + return error_template(404, ex) rescue ex return error_template(500, ex) end diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 207970b0..84da9993 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -7,6 +7,8 @@ module Invidious::Routes::Embed playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 videos = get_playlist_videos(playlist, offset: offset) + rescue ex : NotFoundException + return error_template(404, ex) rescue ex return error_template(500, ex) end @@ -60,6 +62,8 @@ module Invidious::Routes::Embed playlist = get_playlist(plid) offset = env.params.query["index"]?.try &.to_i? || 0 videos = get_playlist_videos(playlist, offset: offset) + rescue ex : NotFoundException + return error_template(404, ex) rescue ex return error_template(500, ex) end @@ -119,6 +123,8 @@ module Invidious::Routes::Embed video = get_video(id, region: params.region) rescue ex : VideoRedirect return env.redirect env.request.resource.gsub(id, ex.video_id) + rescue ex : NotFoundException + return error_template(404, ex) rescue ex return error_template(500, ex) end diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index b5b58399..31120ecb 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -150,6 +150,8 @@ module Invidious::Routes::Feeds channel = get_about_info(ucid, locale) rescue ex : ChannelRedirect return env.redirect env.request.resource.gsub(ucid, ex.channel_id) + rescue ex : NotFoundException + return error_atom(404, ex) rescue ex return error_atom(500, ex) end diff --git a/src/invidious/routes/playlists.cr b/src/invidious/routes/playlists.cr index de981d81..fe7e4e1c 100644 --- a/src/invidious/routes/playlists.cr +++ b/src/invidious/routes/playlists.cr @@ -66,7 +66,13 @@ module Invidious::Routes::Playlists user = user.as(User) playlist_id = env.params.query["list"] - playlist = get_playlist(playlist_id) + begin + playlist = get_playlist(playlist_id) + rescue ex : NotFoundException + return error_template(404, ex) + rescue ex + return error_template(500, ex) + end subscribe_playlist(user, playlist) env.redirect "/playlist?list=#{playlist.id}" @@ -304,6 +310,8 @@ module Invidious::Routes::Playlists playlist_id = env.params.query["playlist_id"] playlist = get_playlist(playlist_id).as(InvidiousPlaylist) raise "Invalid user" if playlist.author != user.email + rescue ex : NotFoundException + return error_json(404, ex) rescue ex if redirect return error_template(400, ex) @@ -334,6 +342,8 @@ module Invidious::Routes::Playlists begin video = get_video(video_id) + rescue ex : NotFoundException + return error_json(404, ex) rescue ex if redirect return error_template(500, ex) @@ -394,6 +404,8 @@ module Invidious::Routes::Playlists begin playlist = get_playlist(plid) + rescue ex : NotFoundException + return error_template(404, ex) rescue ex return error_template(500, ex) end diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index 3a92ef96..560f9c19 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -265,7 +265,13 @@ module Invidious::Routes::VideoPlayback return error_template(403, "Administrator has disabled this endpoint.") end - video = get_video(id, region: region) + begin + video = get_video(id, region: region) + rescue ex : NotFoundException + return error_template(404, ex) + rescue ex + return error_template(500, ex) + end fmt = video.fmt_stream.find(nil) { |f| f["itag"].as_i == itag } || video.adaptive_fmts.find(nil) { |f| f["itag"].as_i == itag } url = fmt.try &.["url"]?.try &.as_s diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 7280de4f..fe1d8e54 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -63,6 +63,9 @@ module Invidious::Routes::Watch video = get_video(id, region: params.region) rescue ex : VideoRedirect return env.redirect env.request.resource.gsub(id, ex.video_id) + rescue ex : NotFoundException + LOGGER.error("get_video not found: #{id} : #{ex.message}") + return error_template(404, ex) rescue ex LOGGER.error("get_video: #{id} : #{ex.message}") return error_template(500, ex) diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index f65b05bb..20204d81 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -1159,7 +1159,11 @@ def fetch_video(id, region) end if reason = info["reason"]? - raise InfoException.new(reason.as_s || "") + if reason == "Video unavailable" + raise NotFoundException.new(reason.as_s || "") + else + raise InfoException.new(reason.as_s || "") + end end video = Video.new({ -- cgit v1.2.3 From e84416e56d3916477b2f2873eb1f4535d2777783 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Sat, 4 Jun 2022 12:58:34 +0200 Subject: Remove dislikes icon (#3092) --- src/invidious/views/watch.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 8b6eb903..367fde33 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -173,7 +173,7 @@ we're going to need to do it here in order to allow for translations.

    <%= number_with_separator(video.views) %>

    <%= number_with_separator(video.likes) %>

    -

    <%= number_with_separator(video.dislikes) %>

    +

    <%= translate(locale, "Genre: ") %> <% if !video.genre_url %> <%= video.genre %> -- cgit v1.2.3 From 4ae77bcef95ccaa0b07bf750d660297c97be89b5 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Sat, 4 Jun 2022 15:39:04 +0200 Subject: Remove rating display from the frontend --- src/invidious/views/watch.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 367fde33..783eff1d 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -186,7 +186,7 @@ we're going to need to do it here in order to allow for translations. <% end %>

    <%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %>

    <%= translate(locale, "Wilson score: ") %><%= video.wilson_score %>

    -

    <%= translate(locale, "Rating: ") %><%= video.average_rating %> / 5

    +

    <%= translate(locale, "Engagement: ") %><%= video.engagement %>%

    <% if video.allowed_regions.size != REGIONS.size %>

    -- cgit v1.2.3 From a402128a7d4a2d3dccbeeb553e5363447f501a37 Mon Sep 17 00:00:00 2001 From: meow Date: Sun, 5 Jun 2022 21:19:59 +0300 Subject: Move `_helpers.js` include from various .ecr's into `template.ecr` `head` tag --- src/invidious/views/add_playlist_items.ecr | 1 - src/invidious/views/community.ecr | 1 - src/invidious/views/components/player.ecr | 1 - src/invidious/views/components/subscribe_widget.ecr | 1 - src/invidious/views/embed.ecr | 1 - src/invidious/views/feeds/history.ecr | 1 - src/invidious/views/feeds/subscriptions.ecr | 1 - src/invidious/views/playlist.ecr | 1 - src/invidious/views/template.ecr | 2 +- src/invidious/views/watch.ecr | 2 -- 10 files changed, 1 insertion(+), 11 deletions(-) (limited to 'src') diff --git a/src/invidious/views/add_playlist_items.ecr b/src/invidious/views/add_playlist_items.ecr index 758f3995..22870317 100644 --- a/src/invidious/views/add_playlist_items.ecr +++ b/src/invidious/views/add_playlist_items.ecr @@ -29,7 +29,6 @@ }.to_pretty_json %> -

    diff --git a/src/invidious/views/community.ecr b/src/invidious/views/community.ecr index 154c40b5..3bc29e55 100644 --- a/src/invidious/views/community.ecr +++ b/src/invidious/views/community.ecr @@ -93,5 +93,4 @@ }.to_pretty_json %> - diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index 483807d7..fffefc9a 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -66,5 +66,4 @@ }.to_pretty_json %> - diff --git a/src/invidious/views/components/subscribe_widget.ecr b/src/invidious/views/components/subscribe_widget.ecr index 7a8c7fda..b9d5f783 100644 --- a/src/invidious/views/components/subscribe_widget.ecr +++ b/src/invidious/views/components/subscribe_widget.ecr @@ -31,7 +31,6 @@ }.to_pretty_json %> - <% else %>

    diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index 82f80f9d..ce5ff7f0 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -31,7 +31,6 @@ <%= rendered "components/player" %> - diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr index 51dd78bd..6c1243c5 100644 --- a/src/invidious/views/feeds/history.ecr +++ b/src/invidious/views/feeds/history.ecr @@ -25,7 +25,6 @@ }.to_pretty_json %> -

    diff --git a/src/invidious/views/feeds/subscriptions.ecr b/src/invidious/views/feeds/subscriptions.ecr index 957277fa..8d56ad14 100644 --- a/src/invidious/views/feeds/subscriptions.ecr +++ b/src/invidious/views/feeds/subscriptions.ecr @@ -50,7 +50,6 @@ }.to_pretty_json %> -
    diff --git a/src/invidious/views/playlist.ecr b/src/invidious/views/playlist.ecr index 641cbe2c..df3112db 100644 --- a/src/invidious/views/playlist.ecr +++ b/src/invidious/views/playlist.ecr @@ -97,7 +97,6 @@ }.to_pretty_json %> - <% end %> diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 79decbe6..4e2b29f0 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -17,6 +17,7 @@ + <% @@ -157,7 +158,6 @@
    - <% if env.get? "user" %> diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index f2d8ba03..861b2048 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -165,7 +165,6 @@ we're going to need to do it here in order to allow for translations. }.to_pretty_json %> - <% end %> <% end %> @@ -304,5 +303,4 @@ we're going to need to do it here in order to allow for translations.
    <% end %> - -- cgit v1.2.3 From 33da64a6696e757aa98b2c771e3e8c03f5e58b2b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 26 May 2022 18:31:02 +0200 Subject: Add support for hashtags --- src/invidious.cr | 1 + src/invidious/hashtag.cr | 44 ++++++++++++++++++++++++++++++++++ src/invidious/routes/search.cr | 31 ++++++++++++++++++++++++ src/invidious/views/hashtag.ecr | 39 ++++++++++++++++++++++++++++++ src/invidious/yt_backend/extractors.cr | 26 ++++++++++++++++++++ 5 files changed, 141 insertions(+) create mode 100644 src/invidious/hashtag.cr create mode 100644 src/invidious/views/hashtag.ecr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index dd240852..4952b365 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -385,6 +385,7 @@ end Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch Invidious::Routing.get "/results", Invidious::Routes::Search, :results Invidious::Routing.get "/search", Invidious::Routes::Search, :search + Invidious::Routing.get "/hashtag/:hashtag", Invidious::Routes::Search, :hashtag # User routes define_user_routes() diff --git a/src/invidious/hashtag.cr b/src/invidious/hashtag.cr new file mode 100644 index 00000000..afe31a36 --- /dev/null +++ b/src/invidious/hashtag.cr @@ -0,0 +1,44 @@ +module Invidious::Hashtag + extend self + + def fetch(hashtag : String, page : Int, region : String? = nil) : Array(SearchItem) + cursor = (page - 1) * 60 + ctoken = generate_continuation(hashtag, cursor) + + client_config = YoutubeAPI::ClientConfig.new(region: region) + response = YoutubeAPI.browse(continuation: ctoken, client_config: client_config) + + return extract_items(response) + end + + def generate_continuation(hashtag : String, cursor : Int) + object = { + "80226972:embedded" => { + "2:string" => "FEhashtag", + "3:base64" => { + "1:varint" => cursor.to_i64, + }, + "7:base64" => { + "325477796:embedded" => { + "1:embedded" => { + "2:0:embedded" => { + "2:string" => '#' + hashtag, + "4:varint" => 0_i64, + "11:string" => "", + }, + "4:string" => "browse-feedFEhashtag", + }, + "2:string" => hashtag, + }, + }, + }, + } + + continuation = object.try { |i| Protodec::Any.cast_json(i) } + .try { |i| Protodec::Any.from_json(i) } + .try { |i| Base64.urlsafe_encode(i) } + .try { |i| URI.encode_www_form(i) } + + return continuation + end +end diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index e60d0081..6f8bffea 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -63,4 +63,35 @@ module Invidious::Routes::Search templated "search" end end + + def self.hashtag(env : HTTP::Server::Context) + locale = env.get("preferences").as(Preferences).locale + + hashtag = env.params.url["hashtag"]? + if hashtag.nil? || hashtag.empty? + return error_template(400, "Invalid request") + end + + page = env.params.query["page"]? + if page.nil? + page = 1 + else + page = Math.max(1, page.to_i) + env.params.query.delete_all("page") + end + + begin + videos = Invidious::Hashtag.fetch(hashtag, page) + rescue ex + return error_template(500, ex) + end + + params = env.params.query.empty? ? "" : "&#{env.params.query}" + + hashtag_encoded = URI.encode_www_form(hashtag, space_to_plus: false) + url_prev_page = "/hashtag/#{hashtag_encoded}?page=#{page - 1}#{params}" + url_next_page = "/hashtag/#{hashtag_encoded}?page=#{page + 1}#{params}" + + templated "hashtag" + end end diff --git a/src/invidious/views/hashtag.ecr b/src/invidious/views/hashtag.ecr new file mode 100644 index 00000000..0ecfe832 --- /dev/null +++ b/src/invidious/views/hashtag.ecr @@ -0,0 +1,39 @@ +<% content_for "header" do %> +<%= HTML.escape(hashtag) %> - Invidious +<% end %> + +
    + +
    +
    + <%- if page > 1 -%> + <%= translate(locale, "Previous page") %> + <%- end -%> +
    +
    +
    + <%- if videos.size >= 60 -%> + <%= translate(locale, "Next page") %> + <%- end -%> +
    +
    + +
    + <%- videos.each do |item| -%> + <%= rendered "components/item" %> + <%- end -%> +
    + +
    +
    + <%- if page > 1 -%> + <%= translate(locale, "Previous page") %> + <%- end -%> +
    +
    +
    + <%- if videos.size >= 60 -%> + <%= translate(locale, "Next page") %> + <%- end -%> +
    +
    diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index a2ec7d59..7e7cf85b 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -1,3 +1,5 @@ +require "../helpers/serialized_yt_data" + # This file contains helper methods to parse the Youtube API json data into # neat little packages we can use @@ -14,6 +16,7 @@ private ITEM_PARSERS = { Parsers::GridPlaylistRendererParser, Parsers::PlaylistRendererParser, Parsers::CategoryRendererParser, + Parsers::RichItemRendererParser, } record AuthorFallback, name : String, id : String @@ -374,6 +377,29 @@ private module Parsers return {{@type.name}} end end + + # Parses an InnerTube richItemRenderer into a SearchVideo. + # Returns nil when the given object isn't a shelfRenderer + # + # A richItemRenderer seems to be a simple wrapper for a videoRenderer, used + # by the result page for hashtags. It is located inside a continuationItems + # container. + # + module RichItemRendererParser + def self.process(item : JSON::Any, author_fallback : AuthorFallback) + if item_contents = item.dig?("richItemRenderer", "content") + return self.parse(item_contents, author_fallback) + end + end + + private def self.parse(item_contents, author_fallback) + return VideoRendererParser.process(item_contents, author_fallback) + end + + def self.parser_name + return {{@type.name}} + end + end end # The following are the extractors for extracting an array of items from -- cgit v1.2.3 From 2b1e1b11a331aea87b6b8e73d8d5bab97ae0f89b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 1 Jun 2022 23:07:18 +0200 Subject: Fix CI: support BADGE_STYLE_TYPE_VERIFIED_ARTIST --- src/invidious/channels/about.cr | 4 +-- src/invidious/routes/feeds.cr | 2 +- src/invidious/videos.cr | 24 ++++++++--------- src/invidious/yt_backend/extractors.cr | 32 ++++++++--------------- src/invidious/yt_backend/extractors_utils.cr | 39 ++++++++++++++++++++++++++++ 5 files changed, 63 insertions(+), 38 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index da71e9a8..565f2bca 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -61,6 +61,7 @@ def get_about_info(ucid, locale) : AboutChannel author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s author_thumbnail = initdata["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].as_s + author_verified = has_verified_badge?(initdata.dig?("header", "c4TabbedHeaderRenderer", "badges")) ucid = initdata["metadata"]["channelMetadataRenderer"]["externalId"].as_s @@ -71,9 +72,6 @@ def get_about_info(ucid, locale) : AboutChannel # if banner.includes? "channels/c4/default_banner" # banner = nil # end - # author_verified_badges = initdata["header"]?.try &.["c4TabbedHeaderRenderer"]?.try &.["badges"]? - author_verified_badge = initdata["header"].dig?("c4TabbedHeaderRenderer", "badges", 0, "metadataBadgeRenderer", "tooltip") - author_verified = (author_verified_badge && author_verified_badge == "Verified") description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]? diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index b5b58399..2e6043f7 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -182,7 +182,7 @@ module Invidious::Routes::Feeds paid: false, premium: false, premiere_timestamp: nil, - author_verified: false, # ¯\_(ツ)_/¯ + author_verified: false, }) end diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index f65b05bb..8ba667db 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -868,11 +868,7 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? .try &.dig?("runs", 0) author = channel_info.try &.dig?("text") - author_verified_badge = related["ownerBadges"]?.try do |badges_array| - badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") - end - - author_verified = (author_verified_badge && author_verified_badge.size > 0).to_s + author_verified = has_verified_badge?(related["ownerBadges"]?) ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } @@ -1089,17 +1085,19 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ # Author infos - author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") - author_thumbnail = author_info.try &.dig?("thumbnail", "thumbnails", 0, "url") + if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") + author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url") + params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") - author_verified_badge = author_info.try &.dig?("badges", 0, "metadataBadgeRenderer", "tooltip") - author_verified = (!author_verified_badge.nil? && author_verified_badge == "Verified") - params["authorVerified"] = JSON::Any.new(author_verified) + author_verified = has_verified_badge?(author_info["badges"]?) + params["authorVerified"] = JSON::Any.new(author_verified) - params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") + subs_text = author_info["subscriberCountText"]? + .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } + .try &.as_s.split(" ", 2)[0] - params["subCountText"] = JSON::Any.new(author_info.try &.["subscriberCountText"]? - .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") }.try &.as_s.split(" ", 2)[0] || "-") + params["subCountText"] = JSON::Any.new(subs_text || "-") + end # Return data diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 7e7cf85b..f394da84 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -60,6 +60,8 @@ private module Parsers author_id = author_fallback.id end + author_verified = has_verified_badge?(item_contents["ownerBadges"]?) + # For live videos (and possibly recently premiered videos) there is no published information. # Instead, in its place is the amount of people currently watching. This behavior should be replicated # on Invidious once all features of livestreams are supported. On an unrelated note, defaulting to the current @@ -105,11 +107,7 @@ private module Parsers premium = false premiere_timestamp = item_contents.dig?("upcomingEventData", "startTime").try { |t| Time.unix(t.as_s.to_i64) } - author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| - badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") - end - author_verified = (author_verified_badge && author_verified_badge.size > 0) item_contents["badges"]?.try &.as_a.each do |badge| b = badge["metadataBadgeRenderer"] case b["label"].as_s @@ -136,7 +134,7 @@ private module Parsers live_now: live_now, premium: premium, premiere_timestamp: premiere_timestamp, - author_verified: author_verified || false, + author_verified: author_verified, }) end @@ -164,12 +162,9 @@ private module Parsers private def self.parse(item_contents, author_fallback) author = extract_text(item_contents["title"]) || author_fallback.name author_id = item_contents["channelId"]?.try &.as_s || author_fallback.id - author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| - badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") - end - - author_verified = (author_verified_badge && author_verified_badge.size > 0) + author_verified = has_verified_badge?(item_contents["ownerBadges"]?) author_thumbnail = HelperExtractors.get_thumbnails(item_contents) + # When public subscriber count is disabled, the subscriberCountText isn't sent by InnerTube. # Always simpleText # TODO change default value to nil @@ -191,7 +186,7 @@ private module Parsers video_count: video_count, description_html: description_html, auto_generated: auto_generated, - author_verified: author_verified || false, + author_verified: author_verified, }) end @@ -219,11 +214,9 @@ private module Parsers private def self.parse(item_contents, author_fallback) title = extract_text(item_contents["title"]) || "" plid = item_contents["playlistId"]?.try &.as_s || "" - author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| - badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") - end - author_verified = (author_verified_badge && author_verified_badge.size > 0) + author_verified = has_verified_badge?(item_contents["ownerBadges"]?) + video_count = HelperExtractors.get_video_count(item_contents) playlist_thumbnail = HelperExtractors.get_thumbnails(item_contents) @@ -235,7 +228,7 @@ private module Parsers video_count: video_count, videos: [] of SearchPlaylistVideo, thumbnail: playlist_thumbnail, - author_verified: author_verified || false, + author_verified: author_verified, }) end @@ -269,11 +262,8 @@ private module Parsers author_info = item_contents.dig?("shortBylineText", "runs", 0) author = author_info.try &.["text"].as_s || author_fallback.name author_id = author_info.try { |x| HelperExtractors.get_browse_id(x) } || author_fallback.id - author_verified_badge = item_contents["ownerBadges"]?.try do |badges_array| - badges_array.as_a.find(&.dig("metadataBadgeRenderer", "tooltip").as_s.== "Verified") - end + author_verified = has_verified_badge?(item_contents["ownerBadges"]?) - author_verified = (author_verified_badge && author_verified_badge.size > 0) videos = item_contents["videos"]?.try &.as_a.map do |v| v = v["childVideoRenderer"] v_title = v.dig?("title", "simpleText").try &.as_s || "" @@ -296,7 +286,7 @@ private module Parsers video_count: video_count, videos: videos, thumbnail: playlist_thumbnail, - author_verified: author_verified || false, + author_verified: author_verified, }) end diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index add5f488..3d5e5787 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -29,6 +29,45 @@ def extract_text(item : JSON::Any?) : String? end end +# Check if an "ownerBadges" or a "badges" element contains a verified badge. +# There is currently two known types of verified badges: +# +# "ownerBadges": [{ +# "metadataBadgeRenderer": { +# "icon": { "iconType": "CHECK_CIRCLE_THICK" }, +# "style": "BADGE_STYLE_TYPE_VERIFIED", +# "tooltip": "Verified", +# "accessibilityData": { "label": "Verified" } +# } +# }], +# +# "ownerBadges": [{ +# "metadataBadgeRenderer": { +# "icon": { "iconType": "OFFICIAL_ARTIST_BADGE" }, +# "style": "BADGE_STYLE_TYPE_VERIFIED_ARTIST", +# "tooltip": "Official Artist Channel", +# "accessibilityData": { "label": "Official Artist Channel" } +# } +# }], +# +def has_verified_badge?(badges : JSON::Any?) + return false if badges.nil? + + badges.as_a.each do |badge| + style = badge.dig("metadataBadgeRenderer", "style").as_s + + return true if style == "BADGE_STYLE_TYPE_VERIFIED" + return true if style == "BADGE_STYLE_TYPE_VERIFIED_ARTIST" + end + + return false +rescue ex + LOGGER.debug("Unable to parse owner badges. Got exception: #{ex.message}") + LOGGER.trace("Owner badges data: #{badges.to_json}") + + return false +end + def extract_videos(initial_data : Hash(String, JSON::Any), author_fallback : String? = nil, author_id_fallback : String? = nil) extracted = extract_items(initial_data, author_fallback, author_id_fallback) -- cgit v1.2.3 From d7f6b6b01869e044fa9a578894beede6562b3c8e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 1 Jun 2022 23:17:28 +0200 Subject: Fix CI: support reloadContinuationItemsCommand containers --- src/invidious/yt_backend/extractors.cr | 2 ++ 1 file changed, 2 insertions(+) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index f394da84..c4326cab 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -517,6 +517,8 @@ private module Extractors self.extract(target) elsif target = initial_data["appendContinuationItemsAction"]? self.extract(target) + elsif target = initial_data["reloadContinuationItemsCommand"]? + self.extract(target) end end -- cgit v1.2.3 From 3593f67eb60d46b4d4503364fe9f52109060cac7 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 8 Jun 2022 23:23:34 +0200 Subject: Fix: related videos is a Hash(String, String) --- src/invidious/videos.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 8ba667db..1504e390 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -853,6 +853,7 @@ end # the same 11 first entries as the compact rendered. # # TODO: "compactRadioRenderer" (Mix) and +# TODO: Use a proper struct/class instead of a hacky JSON object def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? return nil if !related["videoId"]? @@ -868,7 +869,7 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? .try &.dig?("runs", 0) author = channel_info.try &.dig?("text") - author_verified = has_verified_badge?(related["ownerBadges"]?) + author_verified = has_verified_badge?(related["ownerBadges"]?).to_s ucid = channel_info.try { |ci| HelperExtractors.get_browse_id(ci) } -- cgit v1.2.3 From de740569257312ee9326f4ed3ca055c23cbb879d Mon Sep 17 00:00:00 2001 From: 138138138 <78271024+138138138@users.noreply.github.com> Date: Wed, 22 Jun 2022 20:09:29 +0800 Subject: Keep listen mode after related video click When clicking the related videos, listen mode will be kept by passing listen=true/listen=false on the URL. --- src/invidious/views/watch.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index d1fdcce2..c8f0e6f3 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -270,7 +270,7 @@ we're going to need to do it here in order to allow for translations. <% video.related_videos.each do |rv| %> <% if rv["id"]? %> - "> + &listen=<%= params.listen %>"> <% if !env.get("preferences").as(Preferences).thin_mode %>
    /mqdefault.jpg"> -- cgit v1.2.3 From 140b6c1227754356145acd7b76820e3921745ef8 Mon Sep 17 00:00:00 2001 From: 138138138 <78271024+138138138@users.noreply.github.com> Date: Thu, 23 Jun 2022 02:13:22 +0800 Subject: DASH playback force highest quality m4a Since VideoJS is unable to handle adaptive audio quality, the best audo quality is forced for every video quality. --- src/invidious/routes/api/manifest.cr | 11 +++++++++++ 1 file changed, 11 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index 8bc36946..8b5bfc06 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -61,7 +61,18 @@ module Invidious::Routes::API::Manifest next if mime_streams.empty? xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do + # ignore the 64k m4a stream, only consider the 128k m4a stream + best_m4a_stream = mime_streams[0] + best_m4a_stream_bitrate = 0 mime_streams.each do |fmt| + bandwidth = fmt["bitrate"].as_i + if (bandwidth > best_m4a_stream_bitrate) + best_m4a_stream_bitrate = bandwidth + best_m4a_stream = fmt + end + end + + [best_m4a_stream].each do |fmt| # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) -- cgit v1.2.3 From 81abebd14493d4207a663d6f575d945a23b03170 Mon Sep 17 00:00:00 2001 From: 138138138 <78271024+138138138@users.noreply.github.com> Date: Thu, 23 Jun 2022 02:27:46 +0800 Subject: Highest quality m4a on audio only mode as default Audio mode will automatically select highest quality m4a as default. --- src/invidious/views/components/player.ecr | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index fffefc9a..a342097e 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -7,14 +7,25 @@ <% else %> <% if params.listen %> - <% audio_streams.each_with_index do |fmt, i| + <% # ignore the 64k m4a stream, only consider the 128k m4a stream + best_m4a_stream_index = 0 + best_m4a_stream_bitrate = 0 + audio_streams.each_with_index do |fmt, i| + bandwidth = fmt["bitrate"].as_i + if (fmt["mimeType"].as_s.starts_with?("audio/mp4") && bandwidth > best_m4a_stream_bitrate) + best_m4a_stream_bitrate = bandwidth + best_m4a_stream_index = i + end + end + + audio_streams.each_with_index do |fmt, i| src_url = "/latest_version?id=#{video.id}&itag=#{fmt["itag"]}" src_url += "&local=true" if params.local bitrate = fmt["bitrate"] mimetype = HTML.escape(fmt["mimeType"].as_s) - selected = i == 0 ? true : false + selected = i == best_m4a_stream_index ? true : false %> <% if !params.local && !CONFIG.disabled?("local") %> -- cgit v1.2.3 From 3013782b7b39295b34c3f5a72274efc625748a7f Mon Sep 17 00:00:00 2001 From: 138138138 <78271024+138138138@users.noreply.github.com> Date: Thu, 23 Jun 2022 03:03:54 +0800 Subject: formatting --- src/invidious/routes/api/manifest.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index 8b5bfc06..b8466df1 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -71,7 +71,7 @@ module Invidious::Routes::API::Manifest best_m4a_stream = fmt end end - + [best_m4a_stream].each do |fmt| # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) -- cgit v1.2.3 From c75bf35f59864c9f7e37816d657e913f29b40123 Mon Sep 17 00:00:00 2001 From: 138138138 <78271024+138138138@users.noreply.github.com> Date: Fri, 24 Jun 2022 17:26:30 +0800 Subject: Update DASH format to serve 2 audio to player player.audioTracks() can successfully show tracks_: Array(2) --- src/invidious/routes/api/manifest.cr | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index b8466df1..476ff65a 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -46,7 +46,7 @@ module Invidious::Routes::API::Manifest end end - audio_streams = video.audio_streams + audio_streams = video.audio_streams.sort_by { |stream| {stream["bitrate"].as_i} }.reverse! video_streams = video.video_streams.sort_by { |stream| {stream["width"].as_i, stream["fps"].as_i} }.reverse! manifest = XML.build(indent: " ", encoding: "UTF-8") do |xml| @@ -60,19 +60,8 @@ module Invidious::Routes::API::Manifest mime_streams = audio_streams.select { |stream| stream["mimeType"].as_s.starts_with? mime_type } next if mime_streams.empty? - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true) do - # ignore the 64k m4a stream, only consider the 128k m4a stream - best_m4a_stream = mime_streams[0] - best_m4a_stream_bitrate = 0 - mime_streams.each do |fmt| - bandwidth = fmt["bitrate"].as_i - if (bandwidth > best_m4a_stream_bitrate) - best_m4a_stream_bitrate = bandwidth - best_m4a_stream = fmt - end - end - - [best_m4a_stream].each do |fmt| + mime_streams.each do |fmt| + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, lang: i.to_s) do # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) @@ -90,9 +79,8 @@ module Invidious::Routes::API::Manifest end end end + i += 1 end - - i += 1 end potential_heights = {4320, 2160, 1440, 1080, 720, 480, 360, 240, 144} -- cgit v1.2.3 From a62adccd3d2e80377d200cb3890d00eea6dd5c8b Mon Sep 17 00:00:00 2001 From: 138138138 <78271024+138138138@users.noreply.github.com> Date: Sat, 25 Jun 2022 16:33:02 +0800 Subject: change lang to label lang has to be BCP 47 standard. Using label also can let video.js know there are 2 audio tracks. --- src/invidious/routes/api/manifest.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index 476ff65a..b9f81622 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -61,7 +61,7 @@ module Invidious::Routes::API::Manifest next if mime_streams.empty? mime_streams.each do |fmt| - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, lang: i.to_s) do + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: i.to_s) do # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) -- cgit v1.2.3 From e0f6988eb59b08341f781ffb2b6bf47f6ee6ab16 Mon Sep 17 00:00:00 2001 From: 138138138 <78271024+138138138@users.noreply.github.com> Date: Sat, 25 Jun 2022 18:52:34 +0800 Subject: DASH Default to high quality m4a --- src/invidious/routes/api/manifest.cr | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index b9f81622..ca72be26 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -61,7 +61,7 @@ module Invidious::Routes::API::Manifest next if mime_streams.empty? mime_streams.each do |fmt| - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: i.to_s) do + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) @@ -70,6 +70,8 @@ module Invidious::Routes::API::Manifest itag = fmt["itag"].as_i url = fmt["url"].as_s + xml.element("Role", schemeIdUri: "urn:mpeg:dash:role:2011", value: i == 0 ? "main" : "alternate") + xml.element("Representation", id: fmt["itag"], codecs: codecs, bandwidth: bandwidth) do xml.element("AudioChannelConfiguration", schemeIdUri: "urn:mpeg:dash:23003:3:audio_channel_configuration:2011", value: "2") -- cgit v1.2.3 From 3f1d88282ed2878608032ec605fe17e61197d8ed Mon Sep 17 00:00:00 2001 From: 138138138 <78271024+138138138@users.noreply.github.com> Date: Sat, 25 Jun 2022 19:26:14 +0800 Subject: Update some comments --- src/invidious/views/components/player.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index a342097e..9f42ae77 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -7,7 +7,7 @@ <% else %> <% if params.listen %> - <% # ignore the 64k m4a stream, only consider the 128k m4a stream + <% # default to 128k m4a stream best_m4a_stream_index = 0 best_m4a_stream_bitrate = 0 audio_streams.each_with_index do |fmt, i| -- cgit v1.2.3 From 2851d993ad0079433ee9028c4f2e7854096ed9f0 Mon Sep 17 00:00:00 2001 From: 11tuvork28 Date: Sun, 3 Jul 2022 14:03:30 +0200 Subject: updated comment to represent current structure --- src/invidious/yt_backend/extractors.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index c4326cab..b9609eb9 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -417,7 +417,7 @@ private module Extractors # {"tabRenderer": { # "endpoint": {...} # "title": "Playlists", - # "selected": true, + # "selected": true, # Is nil unless tab is selected # "content": {...}, # ... # }} -- cgit v1.2.3 From 15d2cfba90428f8c1bb3e7ce88599078dc0ae6f0 Mon Sep 17 00:00:00 2001 From: 11tuvork28 Date: Sun, 3 Jul 2022 14:03:42 +0200 Subject: Fix `Missing hash key: "selected" (KeyError)` --- src/invidious/yt_backend/extractors_utils.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors_utils.cr b/src/invidious/yt_backend/extractors_utils.cr index 3d5e5787..f8245160 100644 --- a/src/invidious/yt_backend/extractors_utils.cr +++ b/src/invidious/yt_backend/extractors_utils.cr @@ -84,7 +84,7 @@ end def extract_selected_tab(tabs) # Extract the selected tab from the array of tabs Youtube returns - return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"].as_bool)[0]["tabRenderer"] + return selected_target = tabs.as_a.select(&.["tabRenderer"]?.try &.["selected"]?.try &.as_bool)[0]["tabRenderer"] end def fetch_continuation_token(items : Array(JSON::Any)) -- cgit v1.2.3 From a8b72d834231a6b353d7dda31e93b2e4907800fd Mon Sep 17 00:00:00 2001 From: 11tuvork28 Date: Sun, 3 Jul 2022 14:23:34 +0200 Subject: Fixed community tab --- src/invidious/channels/community.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 4701ecbd..4c32ea20 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -13,7 +13,7 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) if !continuation || continuation.empty? initial_data = extract_initial_data(response.body) - body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"].as_bool.== true }[0]? + body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"]?.try &.as_bool == true }[0]? if !body raise InfoException.new("Could not extract community tab.") -- cgit v1.2.3 From 864f27ef72b084461e327640f80aa45a8f250b0f Mon Sep 17 00:00:00 2001 From: 11tuvork28 Date: Sun, 3 Jul 2022 14:59:33 +0200 Subject: switched to extract_selected_tab for the community tab --- src/invidious/channels/community.cr | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/community.cr b/src/invidious/channels/community.cr index 4c32ea20..aaed9567 100644 --- a/src/invidious/channels/community.cr +++ b/src/invidious/channels/community.cr @@ -13,13 +13,11 @@ def fetch_channel_community(ucid, continuation, locale, format, thin_mode) if !continuation || continuation.empty? initial_data = extract_initial_data(response.body) - body = initial_data["contents"]?.try &.["twoColumnBrowseResultsRenderer"]["tabs"].as_a.select { |tab| tab["tabRenderer"]?.try &.["selected"]?.try &.as_bool == true }[0]? + body = extract_selected_tab(initial_data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"])["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"] if !body raise InfoException.new("Could not extract community tab.") end - - body = body["tabRenderer"]["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"] else continuation = produce_channel_community_continuation(ucid, continuation) -- cgit v1.2.3 From 0e3820b634cd94a647af099805d3957cd5c8998c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 14 Jun 2022 00:04:19 +0200 Subject: Add #to_http_params method to Query (Fixes #3148) --- spec/invidious/search/query_spec.cr | 42 +++++++++++++++++++++++++++++++++++++ src/invidious/routes/search.cr | 6 ++++++ src/invidious/search/query.cr | 12 ++++++++++- src/invidious/views/search.ecr | 10 --------- 4 files changed, 59 insertions(+), 11 deletions(-) (limited to 'src') diff --git a/spec/invidious/search/query_spec.cr b/spec/invidious/search/query_spec.cr index 4853e9e9..063b69f1 100644 --- a/spec/invidious/search/query_spec.cr +++ b/spec/invidious/search/query_spec.cr @@ -197,4 +197,46 @@ Spectator.describe Invidious::Search::Query do ) end end + + describe "#to_http_params" do + it "formats regular search" do + query = described_class.new( + HTTP::Params.parse("q=The+Simpsons+hiding+in+bush&duration=short"), + Invidious::Search::Query::Type::Regular, nil + ) + + params = query.to_http_params + + expect(params).to have_key("duration") + expect(params["duration"]?).to eq("short") + + expect(params).to have_key("q") + expect(params["q"]?).to eq("The Simpsons hiding in bush") + + # Check if there aren't other parameters + params.delete("duration") + params.delete("q") + expect(params).to be_empty + end + + it "formats channel search" do + query = described_class.new( + HTTP::Params.parse("q=channel:UC2DjFE7Xf11URZqWBigcVOQ%20multimeter"), + Invidious::Search::Query::Type::Regular, nil + ) + + params = query.to_http_params + + expect(params).to have_key("channel") + expect(params["channel"]?).to eq("UC2DjFE7Xf11URZqWBigcVOQ") + + expect(params).to have_key("q") + expect(params["q"]?).to eq("multimeter") + + # Check if there aren't other parameters + params.delete("channel") + params.delete("q") + expect(params).to be_empty + end + end end diff --git a/src/invidious/routes/search.cr b/src/invidious/routes/search.cr index 6f8bffea..2a9705cf 100644 --- a/src/invidious/routes/search.cr +++ b/src/invidious/routes/search.cr @@ -59,6 +59,12 @@ module Invidious::Routes::Search return error_template(500, ex) end + params = query.to_http_params + url_prev_page = "/search?#{params}&page=#{query.page - 1}" + url_next_page = "/search?#{params}&page=#{query.page + 1}" + + redirect_url = Invidious::Frontend::Misc.redirect_url(env) + env.set "search", query.text templated "search" end diff --git a/src/invidious/search/query.cr b/src/invidious/search/query.cr index 34b36b1d..24e79609 100644 --- a/src/invidious/search/query.cr +++ b/src/invidious/search/query.cr @@ -57,7 +57,7 @@ module Invidious::Search # Get the page number (also common to all search types) @page = params["page"]?.try &.to_i? || 1 - # Stop here is raw query in empty + # Stop here if raw query is empty # NOTE: maybe raise in the future? return if self.empty_raw_query? @@ -127,6 +127,16 @@ module Invidious::Search return items end + # Return the HTTP::Params corresponding to this Query (invidious format) + def to_http_params : HTTP::Params + params = @filters.to_iv_params + + params["q"] = @query + params["channel"] = @channel if !@channel.empty? + + return params + end + # TODO: clean code private def unnest_items(all_items) : Array(SearchItem) items = [] of SearchItem diff --git a/src/invidious/views/search.ecr b/src/invidious/views/search.ecr index 7110703e..254449a1 100644 --- a/src/invidious/views/search.ecr +++ b/src/invidious/views/search.ecr @@ -3,16 +3,6 @@ <% end %> -<%- - search_query_encoded = URI.encode_www_form(query.text, space_to_plus: true) - filter_params = query.filters.to_iv_params - - url_prev_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page - 1}" - url_next_page = "/search?q=#{search_query_encoded}&#{filter_params}&page=#{query.page + 1}" - - redirect_url = Invidious::Frontend::Misc.redirect_url(env) --%> - <%= Invidious::Frontend::SearchFilters.generate(query.filters, query.text, query.page, locale) %>
    -- cgit v1.2.3 From 99bc230fe64512b3f87095bb8111b24e15aa4285 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 15 Jun 2022 21:14:38 +0200 Subject: Fix missing hash key: "availableCountries" (Closes #3047) --- src/invidious/channels/about.cr | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 565f2bca..1d7947a6 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -54,9 +54,6 @@ def get_about_info(ucid, locale) : AboutChannel banner = banners.try &.[-1]?.try &.["url"].as_s? description_node = initdata["header"]["interactiveTabbedHeaderRenderer"]["description"] - - is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool - allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) else author = initdata["metadata"]["channelMetadataRenderer"]["title"].as_s author_url = initdata["metadata"]["channelMetadataRenderer"]["channelUrl"].as_s @@ -74,13 +71,17 @@ def get_about_info(ucid, locale) : AboutChannel # end description_node = initdata["metadata"]["channelMetadataRenderer"]?.try &.["description"]? - - is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool - allowed_regions = initdata["microformat"]["microformatDataRenderer"]["availableCountries"].as_a.map(&.as_s) end + is_family_friendly = initdata["microformat"]["microformatDataRenderer"]["familySafe"].as_bool + + allowed_regions = initdata + .dig?("microformat", "microformatDataRenderer", "availableCountries") + .try &.as_a.map(&.as_s) || [] of String + description = !description_node.nil? ? description_node.as_s : "" description_html = HTML.escape(description) + if !description_node.nil? if description_node.as_h?.nil? description_node = text_to_parsed_content(description_node.as_s) -- cgit v1.2.3 From ce32873ef8450461ba55ec50aed93d427aa23084 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 15 Jun 2022 21:55:33 +0200 Subject: Remove item (video/channel/mix) thumbnail from keyboard navigation tree --- src/invidious/views/components/item.ecr | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index fb7ad1dc..4f3cf279 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -5,7 +5,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - "/> + "/>
    <% end %>

    <%= HTML.escape(item.author) %><% if !item.author_verified.nil? && item.author_verified %> <% end %>

    @@ -23,7 +23,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - "/> + "/>

    <%= translate_count(locale, "generic_videos_count", item.video_count, NumberFormatting::Separator) %>

    <% end %> @@ -36,7 +36,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if item.length_seconds != 0 %>

    <%= recode_length_seconds(item.length_seconds) %>

    <% end %> @@ -51,7 +51,7 @@
    <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if plid_form = env.get?("remove_playlist_items") %> " method="post"> "> @@ -103,7 +103,7 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    - + <% if env.get? "show_watched" %> " method="post"> "> -- cgit v1.2.3 From 06af5a004e4c6cda28e5a4cc13ee47ed3cf9c155 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 15 Jun 2022 22:26:50 +0200 Subject: Remove useless link in item forms (buttons on thumbnail) --- src/invidious/views/components/item.ecr | 23 ++++++----------------- src/invidious/views/feeds/history.ecr | 4 +--- src/invidious/views/user/subscription_manager.ecr | 4 +--- src/invidious/views/user/token_manager.ecr | 4 +--- 4 files changed, 9 insertions(+), 26 deletions(-) (limited to 'src') diff --git a/src/invidious/views/components/item.ecr b/src/invidious/views/components/item.ecr index 4f3cf279..0e959ff2 100644 --- a/src/invidious/views/components/item.ecr +++ b/src/invidious/views/components/item.ecr @@ -52,15 +52,12 @@ <% if !env.get("preferences").as(Preferences).thin_mode %>
    + <% if plid_form = env.get?("remove_playlist_items") %> " method="post"> ">

    - - - +

    <% end %> @@ -108,24 +105,16 @@
    " method="post"> ">

    - - - +

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

    - - - +

    <% end %> diff --git a/src/invidious/views/feeds/history.ecr b/src/invidious/views/feeds/history.ecr index 6c1243c5..471d21db 100644 --- a/src/invidious/views/feeds/history.ecr +++ b/src/invidious/views/feeds/history.ecr @@ -38,9 +38,7 @@
    " method="post"> ">

    - - - +

    diff --git a/src/invidious/views/user/subscription_manager.ecr b/src/invidious/views/user/subscription_manager.ecr index c2a89ca2..c9801f09 100644 --- a/src/invidious/views/user/subscription_manager.ecr +++ b/src/invidious/views/user/subscription_manager.ecr @@ -39,9 +39,7 @@

    " method="post"> "> - - "> - + ">

    diff --git a/src/invidious/views/user/token_manager.ecr b/src/invidious/views/user/token_manager.ecr index 79f905a1..a73fa048 100644 --- a/src/invidious/views/user/token_manager.ecr +++ b/src/invidious/views/user/token_manager.ecr @@ -31,9 +31,7 @@

    " method="post"> "> - - "> - + ">

    -- cgit v1.2.3 From eb226e1dcf4ca88776aa42402e8d80fd5f14ae96 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 21 Jun 2022 01:01:25 +0200 Subject: Remove all backend code related to dislikes --- src/invidious/videos.cr | 41 ++++------------------------------------- src/invidious/views/watch.ecr | 8 ++++---- 2 files changed, 8 insertions(+), 41 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 1504e390..3204c98d 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -323,7 +323,7 @@ struct Video json.field "viewCount", self.views json.field "likeCount", self.likes - json.field "dislikeCount", self.dislikes + json.field "dislikeCount", 0_i64 json.field "paid", self.paid json.field "premium", self.premium @@ -354,7 +354,7 @@ struct Video json.field "lengthSeconds", self.length_seconds json.field "allowRatings", self.allow_ratings - json.field "rating", self.average_rating + json.field "rating", 0_i64 json.field "isListed", self.is_listed json.field "liveNow", self.live_now json.field "isUpcoming", self.is_upcoming @@ -556,11 +556,6 @@ struct Video info["dislikes"]?.try &.as_i64 || 0_i64 end - def average_rating : Float64 - # (likes / (likes + dislikes) * 4 + 1) - info["videoDetails"]["averageRating"]?.try { |t| t.as_f? || t.as_i64?.try &.to_f64 }.try &.round(4) || 0.0 - end - def published : Time info .dig?("microformat", "playerMicroformatRenderer", "publishDate") @@ -813,14 +808,6 @@ struct Video return info.dig?("streamingData", "adaptiveFormats", 0, "projectionType").try &.as_s end - def wilson_score : Float64 - ci_lower_bound(likes, likes + dislikes).round(4) - end - - def engagement : Float64 - (((likes + dislikes) / views) * 100).round(4) - end - def reason : String? info["reason"]?.try &.as_s end @@ -1005,7 +992,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ params["relatedVideos"] = JSON::Any.new(related) - # Likes/dislikes + # Likes toplevel_buttons = video_primary_renderer .try &.dig?("videoActions", "menuRenderer", "topLevelButtons") @@ -1023,30 +1010,10 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ LOGGER.trace("extract_video_info: Found \"likes\" button. Button text is \"#{likes_txt}\"") LOGGER.debug("extract_video_info: Likes count is #{likes}") if likes end - - dislikes_button = toplevel_buttons.as_a - .find(&.dig("toggleButtonRenderer", "defaultIcon", "iconType").as_s.== "DISLIKE") - .try &.["toggleButtonRenderer"] - - if dislikes_button - dislikes_txt = (dislikes_button["defaultText"]? || dislikes_button["toggledText"]?) - .try &.dig?("accessibility", "accessibilityData", "label") - dislikes = dislikes_txt.as_s.gsub(/\D/, "").to_i64? if dislikes_txt - - LOGGER.trace("extract_video_info: Found \"dislikes\" button. Button text is \"#{dislikes_txt}\"") - LOGGER.debug("extract_video_info: Dislikes count is #{dislikes}") if dislikes - end - end - - if likes && likes != 0_i64 && (!dislikes || dislikes == 0_i64) - if rating = player_response.dig?("videoDetails", "averageRating").try { |x| x.as_i64? || x.as_f? } - dislikes = (likes * ((5 - rating)/(rating - 1))).round.to_i64 - LOGGER.debug("extract_video_info: Dislikes count (using fallback method) is #{dislikes}") - end end params["likes"] = JSON::Any.new(likes || 0_i64) - params["dislikes"] = JSON::Any.new(dislikes || 0_i64) + params["dislikes"] = JSON::Any.new(0_i64) # Description diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index d1fdcce2..50c63d21 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -173,7 +173,7 @@ we're going to need to do it here in order to allow for translations.

    <%= number_with_separator(video.views) %>

    <%= number_with_separator(video.likes) %>

    -

    +

    <%= translate(locale, "Genre: ") %> <% if !video.genre_url %> <%= video.genre %> @@ -185,9 +185,9 @@ we're going to need to do it here in order to allow for translations.

    <%= translate(locale, "License: ") %><%= video.license %>

    <% end %>

    <%= translate(locale, "Family friendly? ") %><%= translate_bool(locale, video.is_family_friendly) %>

    -

    <%= translate(locale, "Wilson score: ") %><%= video.wilson_score %>

    -

    -

    <%= translate(locale, "Engagement: ") %><%= video.engagement %>%

    + + + <% if video.allowed_regions.size != REGIONS.size %>

    <% if video.allowed_regions.size < REGIONS.size // 2 %> -- cgit v1.2.3 From f7b1dcc271bb14bf8962c9375c413c0cf01d880b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 23 Jun 2022 21:32:02 +0200 Subject: Don't treat LIVE_STREAM_OFFLINE playability status as an error (fixes #3155) --- src/invidious/videos.cr | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 3204c98d..d9a7d846 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -895,13 +895,20 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) - if player_response.dig?("playabilityStatus", "status").try &.as_s != "OK" + playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s + + if playability_status != "OK" subreason = player_response.dig?("playabilityStatus", "errorScreen", "playerErrorMessageRenderer", "subreason") reason = subreason.try &.[]?("simpleText").try &.as_s reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") reason ||= player_response.dig("playabilityStatus", "reason").as_s + params["reason"] = JSON::Any.new(reason) - return params + + # Stop here if video is not a scheduled livestream + if playability_status != "LIVE_STREAM_OFFLINE" + return params + end end params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil) -- cgit v1.2.3 From 5556a996cdbcdd4ff060a2f46b842220c84f3c94 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Wed, 6 Jul 2022 19:59:05 +0000 Subject: Update comment for NotFoundException --- src/invidious/exceptions.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index 1706ba6a..471a199a 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -19,6 +19,6 @@ class BrokenTubeException < Exception end end -# Exception used to hold the bogus UCID during a channel search. +# Exception threw when an element is not found. class NotFoundException < InfoException end -- cgit v1.2.3 From b19beac5b40bd1efbef1882b2160252ebf9a3134 Mon Sep 17 00:00:00 2001 From: 138138138 <78271024+138138138@users.noreply.github.com> Date: Sun, 10 Jul 2022 16:29:50 +0800 Subject: Update src/invidious/views/components/player.ecr better syntax Co-authored-by: Samantaz Fox --- src/invidious/views/components/player.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/components/player.ecr b/src/invidious/views/components/player.ecr index 9f42ae77..c3c02df0 100644 --- a/src/invidious/views/components/player.ecr +++ b/src/invidious/views/components/player.ecr @@ -25,7 +25,7 @@ bitrate = fmt["bitrate"] mimetype = HTML.escape(fmt["mimeType"].as_s) - selected = i == best_m4a_stream_index ? true : false + selected = (i == best_m4a_stream_index) %> <% if !params.local && !CONFIG.disabled?("local") %> -- cgit v1.2.3 From cbcf31a4f98706ea675cafb7509b37dc2b0ceace Mon Sep 17 00:00:00 2001 From: 138138138 <78271024+138138138@users.noreply.github.com> Date: Sun, 10 Jul 2022 16:54:56 +0800 Subject: Skip OTF streams in DASH audio Skip OTF streams, prevent creating empty AdaptationSet in DASH audio --- src/invidious/routes/api/manifest.cr | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index ca72be26..52b94175 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -61,10 +61,10 @@ module Invidious::Routes::API::Manifest next if mime_streams.empty? mime_streams.each do |fmt| - xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do - # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) - next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) + # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) + next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) + xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') bandwidth = fmt["bitrate"].as_i itag = fmt["itag"].as_i -- cgit v1.2.3 From 69ad57338f38662fb0a4d22aa58bec8dc7a5742c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 11 Jul 2022 17:24:03 +0200 Subject: Mention why we use multiple AdaptationSet for audio --- src/invidious/routes/api/manifest.cr | 4 ++++ 1 file changed, 4 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/api/manifest.cr b/src/invidious/routes/api/manifest.cr index 52b94175..a857d18f 100644 --- a/src/invidious/routes/api/manifest.cr +++ b/src/invidious/routes/api/manifest.cr @@ -64,6 +64,10 @@ module Invidious::Routes::API::Manifest # OTF streams aren't supported yet (See https://github.com/TeamNewPipe/NewPipe/issues/2415) next if !(fmt.has_key?("indexRange") && fmt.has_key?("initRange")) + # Different representations of the same audio should be groupped into one AdaptationSet. + # However, most players don't support auto quality switching, so we have to trick them + # into providing a quality selector. + # See https://github.com/iv-org/invidious/issues/3074 for more details. xml.element("AdaptationSet", id: i, mimeType: mime_type, startWithSAP: 1, subsegmentAlignment: true, label: fmt["bitrate"].to_s + "k") do codecs = fmt["mimeType"].as_s.split("codecs=")[1].strip('"') bandwidth = fmt["bitrate"].as_i -- cgit v1.2.3 From 586000ca3d006959d23b8c78eafc55b1143f0aeb Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Tue, 12 Jul 2022 08:38:22 +0000 Subject: add more explanation about checking the player dependencies --- src/invidious.cr | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 4952b365..070b4d18 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -133,12 +133,13 @@ Invidious::Database.check_integrity(CONFIG) # Running the script by itself would show some colorful feedback while this doesn't. # Perhaps we should just move the script to runtime in order to get that feedback? - {% puts "\nChecking player dependencies...\n" %} + {% puts "\nChecking player dependencies, this may take more than 20 minutes... If it is stuck, check your internet connection.\n" %} {% if flag?(:minified_player_dependencies) %} {% puts run("../scripts/fetch-player-dependencies.cr", "--minified").stringify %} {% else %} {% puts run("../scripts/fetch-player-dependencies.cr").stringify %} {% end %} + {% puts "\nDone checking player dependencies, now compiling Invidious...\n" %} {% end %} # Start jobs -- cgit v1.2.3 From 0338b26d5c7f9bc4442db3ef99e5490c282db250 Mon Sep 17 00:00:00 2001 From: AHOHNMYC <24810600+AHOHNMYC@users.noreply.github.com> Date: Thu, 14 Jul 2022 02:07:19 +0300 Subject: Include `_helpers.js` in embedded view --- src/invidious/views/embed.ecr | 1 + 1 file changed, 1 insertion(+) (limited to 'src') diff --git a/src/invidious/views/embed.ecr b/src/invidious/views/embed.ecr index ce5ff7f0..1bf5cc3e 100644 --- a/src/invidious/views/embed.ecr +++ b/src/invidious/views/embed.ecr @@ -11,6 +11,7 @@ <%= HTML.escape(video.title) %> - Invidious + -- cgit v1.2.3 From c8765385df16fff90cff82e1ed1e2056a4bc0ac3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Thu, 14 Jul 2022 17:56:53 +0200 Subject: Fetch data from next endpoint for scheduled streams --- src/invidious/videos.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 19ee064c..50bb80c1 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -914,7 +914,7 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil) # Don't fetch the next endpoint if the video is unavailable. - if !params["reason"]? + if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status) next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) player_response = player_response.merge(next_response) end -- cgit v1.2.3 From 6c4ed282bb8e2a6ed0c756ea012f6b1fa8e6cc48 Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Thu, 14 Jul 2022 21:26:58 +0000 Subject: HTML escape username --- src/invidious/views/template.ecr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index 4e2b29f0..caf5299f 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -68,7 +68,7 @@

    <% if env.get("preferences").as(Preferences).show_nick %>
    - <%= env.get("user").as(Invidious::User).email %> + <%= HTML.escape(env.get("user").as(Invidious::User).email) %>
    <% end %>
    -- cgit v1.2.3 From 049ed114fd2d7c3debf6277935d6dbf5aca6777a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Mon, 18 Jul 2022 23:35:34 +0200 Subject: Separate video data fetching from parsing in videos.cr --- src/invidious/videos.cr | 78 ++++++++++++++++++++++++++++--------------------- 1 file changed, 44 insertions(+), 34 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index 50bb80c1..f87c6b47 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -886,13 +886,13 @@ def parse_related_video(related : JSON::Any) : Hash(String, JSON::Any)? end def extract_video_info(video_id : String, proxy_region : String? = nil, context_screen : String? = nil) - params = {} of String => JSON::Any - + # Init client config for the API client_config = YoutubeAPI::ClientConfig.new(proxy_region: proxy_region) if context_screen == "embed" client_config.client_type = YoutubeAPI::ClientType::TvHtml5ScreenEmbed end + # Fetch data from the player endpoint player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s @@ -903,26 +903,29 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ reason ||= subreason.try &.[]("runs").as_a.map(&.[]("text")).join("") reason ||= player_response.dig("playabilityStatus", "reason").as_s - params["reason"] = JSON::Any.new(reason) - # Stop here if video is not a scheduled livestream if playability_status != "LIVE_STREAM_OFFLINE" - return params + return { + "reason" => JSON::Any.new(reason), + } end + else + reason = nil end - params["shortDescription"] = player_response.dig?("videoDetails", "shortDescription") || JSON::Any.new(nil) - # Don't fetch the next endpoint if the video is unavailable. if {"OK", "LIVE_STREAM_OFFLINE"}.any?(playability_status) next_response = YoutubeAPI.next({"videoId": video_id, "params": ""}) player_response = player_response.merge(next_response) end + params = parse_video_info(video_id, player_response) + params["reason"] = JSON::Any.new(reason) if reason + # Fetch the video streams using an Android client in order to get the decrypted URLs and # maybe fix throttling issues (#2194).See for the explanation about the decrypted URLs: # https://github.com/TeamNewPipe/NewPipeExtractor/issues/562 - if !params["reason"]? + if reason.nil? if context_screen == "embed" client_config.client_type = YoutubeAPI::ClientType::AndroidScreenEmbed else @@ -940,10 +943,15 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end end + # TODO: clean that up {"captions", "microformat", "playabilityStatus", "storyboards", "videoDetails"}.each do |f| params[f] = player_response[f] if player_response[f]? end + return params +end + +def parse_video_info(video_id : String, player_response : Hash(String, JSON::Any)) : Hash(String, JSON::Any) # Top level elements main_results = player_response.dig?("contents", "twoColumnWatchNextResults") @@ -997,8 +1005,6 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end end - params["relatedVideos"] = JSON::Any.new(related) - # Likes toplevel_buttons = video_primary_renderer @@ -1019,42 +1025,36 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end end - params["likes"] = JSON::Any.new(likes || 0_i64) - params["dislikes"] = JSON::Any.new(0_i64) - # Description + short_description = player_response.dig?("videoDetails", "shortDescription") + description_html = video_secondary_renderer.try &.dig?("description", "runs") .try &.as_a.try { |t| content_to_comment_html(t, video_id) } - params["descriptionHtml"] = JSON::Any.new(description_html || "

    ") - # Video metadata metadata = video_secondary_renderer .try &.dig?("metadataRowContainer", "metadataRowContainerRenderer", "rows") .try &.as_a - params["genre"] = params["microformat"]?.try &.["playerMicroformatRenderer"]?.try &.["category"]? || JSON::Any.new("") - params["genreUrl"] = JSON::Any.new(nil) + genre = player_response.dig?("microformat", "playerMicroformatRenderer", "category") + genre_ucid = nil + license = nil metadata.try &.each do |row| - title = row["metadataRowRenderer"]?.try &.["title"]?.try &.["simpleText"]?.try &.as_s + metadata_title = row.dig?("metadataRowRenderer", "title", "simpleText").try &.as_s contents = row.dig?("metadataRowRenderer", "contents", 0) - if title.try &.== "Category" + if metadata_title == "Category" contents = contents.try &.dig?("runs", 0) - params["genre"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") - params["genreUcid"] = JSON::Any.new(contents.try &.["navigationEndpoint"]?.try &.["browseEndpoint"]? - .try &.["browseId"]?.try &.as_s || "") - elsif title.try &.== "License" - contents = contents.try &.["runs"]? - .try &.as_a[0]? - - params["license"] = JSON::Any.new(contents.try &.["text"]?.try &.as_s || "") - elsif title.try &.== "Licensed to YouTube by" - params["license"] = JSON::Any.new(contents.try &.["simpleText"]?.try &.as_s || "") + genre = contents.try &.["text"]? + genre_ucid = contents.try &.dig?("navigationEndpoint", "browseEndpoint", "browseId") + elsif metadata_title == "License" + license = contents.try &.dig?("runs", 0, "text") + elsif metadata_title == "Licensed to YouTube by" + license = contents.try &.["simpleText"]? end end @@ -1062,20 +1062,30 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ if author_info = video_secondary_renderer.try &.dig?("owner", "videoOwnerRenderer") author_thumbnail = author_info.dig?("thumbnail", "thumbnails", 0, "url") - params["authorThumbnail"] = JSON::Any.new(author_thumbnail.try &.as_s || "") - author_verified = has_verified_badge?(author_info["badges"]?) - params["authorVerified"] = JSON::Any.new(author_verified) subs_text = author_info["subscriberCountText"]? .try { |t| t["simpleText"]? || t.dig?("runs", 0, "text") } .try &.as_s.split(" ", 2)[0] - - params["subCountText"] = JSON::Any.new(subs_text || "-") end # Return data + params = { + "shortDescription" => JSON::Any.new(short_description.try &.as_s || nil), + "relatedVideos" => JSON::Any.new(related), + "likes" => JSON::Any.new(likes || 0_i64), + "dislikes" => JSON::Any.new(0_i64), + "descriptionHtml" => JSON::Any.new(description_html || "

    "), + "genre" => JSON::Any.new(genre.try &.as_s || ""), + "genreUrl" => JSON::Any.new(nil), + "genreUcid" => JSON::Any.new(genre_ucid.try &.as_s || ""), + "license" => JSON::Any.new(license.try &.as_s || ""), + "authorThumbnail" => JSON::Any.new(author_thumbnail.try &.as_s || ""), + "authorVerified" => JSON::Any.new(author_verified), + "subCountText" => JSON::Any.new(subs_text || "-"), + } + return params end -- cgit v1.2.3 From 7e648840a1215ddeb8b110eb867893826b73384c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 19 Jul 2022 21:05:49 +0200 Subject: Move InfoException to exceptions.cr --- src/invidious/exceptions.cr | 8 ++++++++ src/invidious/helpers/errors.cr | 8 -------- 2 files changed, 8 insertions(+), 8 deletions(-) (limited to 'src') diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index 471a199a..05be73a6 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -1,3 +1,11 @@ +# InfoExceptions are for displaying information to the user. +# +# An InfoException might or might not indicate that something went wrong. +# Historically Invidious didn't differentiate between these two options, so to +# maintain previous functionality InfoExceptions do not print backtraces. +class InfoException < Exception +end + # Exception used to hold the bogus UCID during a channel search. class ChannelSearchException < InfoException getter channel : String diff --git a/src/invidious/helpers/errors.cr b/src/invidious/helpers/errors.cr index b80dcdaf..6e5a975d 100644 --- a/src/invidious/helpers/errors.cr +++ b/src/invidious/helpers/errors.cr @@ -1,11 +1,3 @@ -# InfoExceptions are for displaying information to the user. -# -# An InfoException might or might not indicate that something went wrong. -# Historically Invidious didn't differentiate between these two options, so to -# maintain previous functionality InfoExceptions do not print backtraces. -class InfoException < Exception -end - # ------------------- # Issue template # ------------------- -- cgit v1.2.3 From 5df700a56e93e777666817b43765bb63f311ea5f Mon Sep 17 00:00:00 2001 From: Mateusz Jabłoński Date: Sat, 6 Aug 2022 17:14:17 +0200 Subject: Added image tag to RSS channel for favicon rendering https://validator.w3.org/feed/docs/rss2.html#ltimagegtSubelementOfLtchannelgt --- src/invidious/routes/feeds.cr | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'src') diff --git a/src/invidious/routes/feeds.cr b/src/invidious/routes/feeds.cr index 44a87175..b601db94 100644 --- a/src/invidious/routes/feeds.cr +++ b/src/invidious/routes/feeds.cr @@ -204,6 +204,12 @@ module Invidious::Routes::Feeds xml.element("uri") { xml.text "#{HOST_URL}/channel/#{channel.ucid}" } end + xml.element("image") do + xml.element("url") { xml.text channel.author_thumbnail } + xml.element("title") { xml.text channel.author } + xml.element("link", rel: "self", href: "#{HOST_URL}#{env.request.resource}") + end + videos.each do |video| video.to_xml(channel.auto_generated, params, xml) end -- cgit v1.2.3 From b55c1a35bf8af2626f9eccdb371e54a9c2c771a2 Mon Sep 17 00:00:00 2001 From: Emilien Devos Date: Sat, 6 Aug 2022 19:01:57 +0200 Subject: Set cookies to Lax --- src/invidious/user/cookies.cr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/user/cookies.cr b/src/invidious/user/cookies.cr index 65e079ec..654efc15 100644 --- a/src/invidious/user/cookies.cr +++ b/src/invidious/user/cookies.cr @@ -18,7 +18,7 @@ struct Invidious::User expires: Time.utc + 2.years, secure: SECURE, http_only: true, - samesite: HTTP::Cookie::SameSite::Strict + samesite: HTTP::Cookie::SameSite::Lax ) end @@ -32,7 +32,7 @@ struct Invidious::User expires: Time.utc + 2.years, secure: SECURE, http_only: false, - samesite: HTTP::Cookie::SameSite::Strict + samesite: HTTP::Cookie::SameSite::Lax ) end end -- cgit v1.2.3 From 3d77642a1e2a94c1314a59b60279157ae4f49b9e Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Sat, 6 Aug 2022 19:09:10 +0200 Subject: Disable decrypt_polling by default + add comment (#3244) --- config/config.example.yml | 7 +++++-- src/invidious/config.cr | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/config/config.example.yml b/config/config.example.yml index 3e8faf20..10734c3a 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -352,10 +352,13 @@ feed_threads: 1 ## Note: This part of the code generate a small amount of data every minute. ## This may not be desired if you have bandwidth limits set by your ISP. ## +## Note 2: This part of the code is currently broken, so changing +## this setting has no impact. +## ## Accepted values: true, false -## Default: true +## Default: false ## -#decrypt_polling: true +#decrypt_polling: false # ----------------------------- diff --git a/src/invidious/config.cr b/src/invidious/config.cr index a077c7fd..786b65df 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -75,7 +75,7 @@ class Config @[YAML::Field(converter: Preferences::URIConverter)] property database_url : URI = URI.parse("") # Use polling to keep decryption function up to date - property decrypt_polling : Bool = true + property decrypt_polling : Bool = false # Used for crawling channels: threads should check all videos uploaded by a channel property full_refresh : Bool = false # Used to tell Invidious it is behind a proxy, so links to resources should be https:// -- cgit v1.2.3 From fc97929dee4f57ac634d9c2dcd5aa77d5c3f70e3 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 Aug 2022 23:28:19 +0200 Subject: Bump android app version --- src/invidious/yt_backend/youtube_api.cr | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 2678ac6c..d2073f73 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -5,6 +5,8 @@ module YoutubeAPI extend self + private ANDROID_APP_VERSION = "17.29.35" + # Enumerate used to select one of the clients supported by the API enum ClientType Web @@ -45,19 +47,19 @@ module YoutubeAPI }, ClientType::Android => { name: "ANDROID", - version: "16.20", + version: ANDROID_APP_VERSION, api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", screen: "", # ?? }, ClientType::AndroidEmbeddedPlayer => { name: "ANDROID_EMBEDDED_PLAYER", # 55 - version: "16.20", + version: ANDROID_APP_VERSION, api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "", # None? }, ClientType::AndroidScreenEmbed => { name: "ANDROID", # 3 - version: "16.20", + version: ANDROID_APP_VERSION, api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "EMBED", }, -- cgit v1.2.3 From f353589a5343448941eb3a7231c14fbff6cc00bf Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 Aug 2022 23:47:16 +0200 Subject: Bump web clients versions --- src/invidious/yt_backend/youtube_api.cr | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index d2073f73..31be285a 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -23,25 +23,25 @@ module YoutubeAPI HARDCODED_CLIENTS = { ClientType::Web => { name: "WEB", - version: "2.20210721.00.00", + version: "2.20220804.07.00", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "WATCH_FULL_SCREEN", }, ClientType::WebEmbeddedPlayer => { name: "WEB_EMBEDDED_PLAYER", # 56 - version: "1.20210721.1.0", + version: "1.20220803.01.00", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "EMBED", }, ClientType::WebMobile => { name: "MWEB", - version: "2.20210726.08.00", + version: "2.20220805.01.00", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "", # None }, ClientType::WebScreenEmbed => { name: "WEB", - version: "2.20210721.00.00", + version: "2.20220804.00.00", api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", screen: "EMBED", }, -- cgit v1.2.3 From 9e7c2dcdbb9c7af4ae1e91c3322deda6615b8fcf Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sat, 6 Aug 2022 23:49:36 +0200 Subject: Move the default API key to a constant for clarity --- src/invidious/yt_backend/youtube_api.cr | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 31be285a..b5b01286 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -5,6 +5,8 @@ module YoutubeAPI extend self + private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" + private ANDROID_APP_VERSION = "17.29.35" # Enumerate used to select one of the clients supported by the API @@ -24,25 +26,25 @@ module YoutubeAPI ClientType::Web => { name: "WEB", version: "2.20220804.07.00", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + api_key: DEFAULT_API_KEY, screen: "WATCH_FULL_SCREEN", }, ClientType::WebEmbeddedPlayer => { name: "WEB_EMBEDDED_PLAYER", # 56 version: "1.20220803.01.00", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + api_key: DEFAULT_API_KEY, screen: "EMBED", }, ClientType::WebMobile => { name: "MWEB", version: "2.20220805.01.00", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + api_key: DEFAULT_API_KEY, screen: "", # None }, ClientType::WebScreenEmbed => { name: "WEB", version: "2.20220804.00.00", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + api_key: DEFAULT_API_KEY, screen: "EMBED", }, ClientType::Android => { @@ -54,19 +56,19 @@ module YoutubeAPI ClientType::AndroidEmbeddedPlayer => { name: "ANDROID_EMBEDDED_PLAYER", # 55 version: ANDROID_APP_VERSION, - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + api_key: DEFAULT_API_KEY, screen: "", # None? }, ClientType::AndroidScreenEmbed => { name: "ANDROID", # 3 version: ANDROID_APP_VERSION, - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + api_key: DEFAULT_API_KEY, screen: "EMBED", }, ClientType::TvHtml5ScreenEmbed => { name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", version: "2.0", - api_key: "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8", + api_key: DEFAULT_API_KEY, screen: "EMBED", }, } -- cgit v1.2.3 From 349d90b60e3cece2a125669688e978932d8d9795 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 7 Aug 2022 00:24:35 +0200 Subject: Add IOS clients --- src/invidious/yt_backend/youtube_api.cr | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index b5b01286..c66b155e 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -8,6 +8,7 @@ module YoutubeAPI private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" private ANDROID_APP_VERSION = "17.29.35" + private IOS_APP_VERSION = "17.30.1" # Enumerate used to select one of the clients supported by the API enum ClientType @@ -15,9 +16,15 @@ module YoutubeAPI WebEmbeddedPlayer WebMobile WebScreenEmbed + Android AndroidEmbeddedPlayer AndroidScreenEmbed + + IOS + IOSEmbedded + IOSMusic + TvHtml5ScreenEmbed end @@ -47,6 +54,9 @@ module YoutubeAPI api_key: DEFAULT_API_KEY, screen: "EMBED", }, + + # Android + ClientType::Android => { name: "ANDROID", version: ANDROID_APP_VERSION, @@ -65,6 +75,27 @@ module YoutubeAPI api_key: DEFAULT_API_KEY, screen: "EMBED", }, + + # IOS + + ClientType::IOS => { + name: "IOS", # 5 + version: IOS_APP_VERSION, + api_key: "AIzaSyB-63vPrdThhKuerbB2N_l7Kwwcxj6yUAc", + }, + ClientType::IOSEmbedded => { + name: "IOS_MESSAGES_EXTENSION", # 66 + version: IOS_APP_VERSION, + api_key: DEFAULT_API_KEY, + }, + ClientType::IOSMusic => { + name: "IOS_MUSIC", # 26 + version: "4.32", + api_key: "AIzaSyBAETezhkwP0ZWA02RsqT1zu78Fpt0bC_s", + }, + + # TV app + ClientType::TvHtml5ScreenEmbed => { name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", version: "2.0", @@ -135,7 +166,7 @@ module YoutubeAPI # :ditto: def screen : String - HARDCODED_CLIENTS[@client_type][:screen] + HARDCODED_CLIENTS[@client_type][:screen]? || "" end # Convert to string, for logging purposes -- cgit v1.2.3 From 618ab01cd75fe43ff8c47fc2454e12eedd41b56e Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 7 Aug 2022 00:36:22 +0200 Subject: Add TVHtml5 client --- src/invidious/yt_backend/youtube_api.cr | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index c66b155e..c8e61539 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -25,6 +25,7 @@ module YoutubeAPI IOSEmbedded IOSMusic + TvHtml5 TvHtml5ScreenEmbed end @@ -96,8 +97,13 @@ module YoutubeAPI # TV app + ClientType::TvHtml5 => { + name: "TVHTML5", # 7 + version: "7.20220325", + api_key: DEFAULT_API_KEY, + }, ClientType::TvHtml5ScreenEmbed => { - name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", + name: "TVHTML5_SIMPLY_EMBEDDED_PLAYER", # 85 version: "2.0", api_key: DEFAULT_API_KEY, screen: "EMBED", -- cgit v1.2.3 From 23855c09dc2988947d7ee63ab4c3f8590660884b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Sun, 7 Aug 2022 00:37:09 +0200 Subject: Remove 'screen' where not required --- src/invidious/yt_backend/youtube_api.cr | 3 --- 1 file changed, 3 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index c8e61539..2b3db742 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -47,7 +47,6 @@ module YoutubeAPI name: "MWEB", version: "2.20220805.01.00", api_key: DEFAULT_API_KEY, - screen: "", # None }, ClientType::WebScreenEmbed => { name: "WEB", @@ -62,13 +61,11 @@ module YoutubeAPI name: "ANDROID", version: ANDROID_APP_VERSION, api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", - screen: "", # ?? }, ClientType::AndroidEmbeddedPlayer => { name: "ANDROID_EMBEDDED_PLAYER", # 55 version: ANDROID_APP_VERSION, api_key: DEFAULT_API_KEY, - screen: "", # None? }, ClientType::AndroidScreenEmbed => { name: "ANDROID", # 3 -- cgit v1.2.3 From 246955b68a16aefc4e682e8f704f551f4a72b1bf Mon Sep 17 00:00:00 2001 From: Emilien Devos Date: Sat, 6 Aug 2022 18:41:59 +0200 Subject: if case for sectionListRenderer --- src/invidious/yt_backend/extractors.cr | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b9609eb9..dc65cc52 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -435,20 +435,22 @@ private module Extractors raw_items = [] of JSON::Any content = extract_selected_tab(target["tabs"])["content"] - content["sectionListRenderer"]["contents"].as_a.each do |renderer_container| - renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] - - # Category extraction - if items_container = renderer_container_contents["shelfRenderer"]? - raw_items << renderer_container_contents - next - elsif items_container = renderer_container_contents["gridRenderer"]? - else - items_container = renderer_container_contents - end + if section_list_contents = content.dig?("sectionListRenderer", "contents") + section_list_contents.as_a.each do |renderer_container| + renderer_container_contents = renderer_container["itemSectionRenderer"]["contents"][0] + + # Category extraction + if items_container = renderer_container_contents["shelfRenderer"]? + raw_items << renderer_container_contents + next + elsif items_container = renderer_container_contents["gridRenderer"]? + else + items_container = renderer_container_contents + end - items_container["items"]?.try &.as_a.each do |item| - raw_items << item + items_container["items"]?.try &.as_a.each do |item| + raw_items << item + end end end -- cgit v1.2.3 From 218f7be1a7ec6cb679d7d324be3e64c6d79da127 Mon Sep 17 00:00:00 2001 From: Emilien Devos Date: Sun, 7 Aug 2022 19:14:16 +0200 Subject: For android client send sdk version to youtube --- src/invidious/yt_backend/youtube_api.cr | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) (limited to 'src') diff --git a/src/invidious/yt_backend/youtube_api.cr b/src/invidious/yt_backend/youtube_api.cr index 2b3db742..30d7613b 100644 --- a/src/invidious/yt_backend/youtube_api.cr +++ b/src/invidious/yt_backend/youtube_api.cr @@ -8,6 +8,7 @@ module YoutubeAPI private DEFAULT_API_KEY = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" private ANDROID_APP_VERSION = "17.29.35" + private ANDROID_SDK_VERSION = 30_i64 private IOS_APP_VERSION = "17.30.1" # Enumerate used to select one of the clients supported by the API @@ -58,9 +59,10 @@ module YoutubeAPI # Android ClientType::Android => { - name: "ANDROID", - version: ANDROID_APP_VERSION, - api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", + name: "ANDROID", + version: ANDROID_APP_VERSION, + api_key: "AIzaSyA8eiZmM1FaDVjRy-df2KTyQ_vz_yYM39w", + android_sdk_version: ANDROID_SDK_VERSION, }, ClientType::AndroidEmbeddedPlayer => { name: "ANDROID_EMBEDDED_PLAYER", # 55 @@ -68,10 +70,11 @@ module YoutubeAPI api_key: DEFAULT_API_KEY, }, ClientType::AndroidScreenEmbed => { - name: "ANDROID", # 3 - version: ANDROID_APP_VERSION, - api_key: DEFAULT_API_KEY, - screen: "EMBED", + name: "ANDROID", # 3 + version: ANDROID_APP_VERSION, + api_key: DEFAULT_API_KEY, + screen: "EMBED", + android_sdk_version: ANDROID_SDK_VERSION, }, # IOS @@ -172,6 +175,10 @@ module YoutubeAPI HARDCODED_CLIENTS[@client_type][:screen]? || "" end + def android_sdk_version : Int64? + HARDCODED_CLIENTS[@client_type][:android_sdk_version]? + end + # Convert to string, for logging purposes def to_s return { @@ -201,7 +208,7 @@ module YoutubeAPI "gl" => client_config.region || "US", # Can't be empty! "clientName" => client_config.name, "clientVersion" => client_config.version, - }, + } of String => String | Int64, } # Add some more context if it exists in the client definitions @@ -212,7 +219,11 @@ module YoutubeAPI if client_config.screen == "EMBED" client_context["thirdParty"] = { "embedUrl" => "https://www.youtube.com/embed/dQw4w9WgXcQ", - } + } of String => String | Int64 + end + + if android_sdk_version = client_config.android_sdk_version + client_context["client"]["androidSdkVersion"] = android_sdk_version end return client_context -- cgit v1.2.3 From 7f2ec183721c55ea5718119e76c3fc6ce6cd72bf Mon Sep 17 00:00:00 2001 From: Émilien Devos Date: Tue, 9 Aug 2022 10:05:13 +0200 Subject: Add param 8AEB for getting youtube stories --- src/invidious/videos.cr | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/videos.cr b/src/invidious/videos.cr index f87c6b47..e9526c18 100644 --- a/src/invidious/videos.cr +++ b/src/invidious/videos.cr @@ -893,7 +893,8 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ end # Fetch data from the player endpoint - player_response = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) + # 8AEB param for fetching YouTube stories + player_response = YoutubeAPI.player(video_id: video_id, params: "8AEB", client_config: client_config) playability_status = player_response.dig?("playabilityStatus", "status").try &.as_s @@ -931,7 +932,8 @@ def extract_video_info(video_id : String, proxy_region : String? = nil, context_ else client_config.client_type = YoutubeAPI::ClientType::Android end - android_player = YoutubeAPI.player(video_id: video_id, params: "", client_config: client_config) + # 8AEB param for fetching YouTube stories + android_player = YoutubeAPI.player(video_id: video_id, params: "8AEB", client_config: client_config) # Sometime, the video is available from the web client, but not on Android, so check # that here, and fallback to the streaming data from the web client if needed. -- cgit v1.2.3 From c23ad25899152c4837777dbc983809f436f7062a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 9 Aug 2022 23:39:53 +0200 Subject: routing: remove HEAD from HTTP methods Kemal automatically creates an associated HEAD route for all GET routes --- src/invidious/routing.cr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index bd72c577..9e95f7db 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -1,5 +1,5 @@ module Invidious::Routing - {% for http_method in {"get", "post", "delete", "options", "patch", "put", "head"} %} + {% for http_method in {"get", "post", "delete", "options", "patch", "put"} %} macro {{http_method.id}}(path, controller, method = :handle) {{http_method.id}} \{{ path }} do |env| -- cgit v1.2.3 From e22cc73f32577afe8098c70184760d8b75ce0189 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Tue, 9 Aug 2022 23:56:34 +0200 Subject: routing: register user routes with a function, rather than a macro --- src/invidious.cr | 5 +---- src/invidious/routing.cr | 56 ++++++++++++++++++++++++++---------------------- 2 files changed, 31 insertions(+), 30 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 070b4d18..91bf6935 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -389,7 +389,7 @@ end Invidious::Routing.get "/hashtag/:hashtag", Invidious::Routes::Search, :hashtag # User routes - define_user_routes() + Invidious::Routing.register_user_routes # Feeds Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect @@ -410,9 +410,6 @@ end Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify - - Invidious::Routing.post "/subscription_ajax", Invidious::Routes::Subscriptions, :toggle_subscription - Invidious::Routing.get "/subscription_manager", Invidious::Routes::Subscriptions, :subscription_manager {% end %} Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 9e95f7db..23119e62 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -1,4 +1,6 @@ module Invidious::Routing + extend self + {% for http_method in {"get", "post", "delete", "options", "patch", "put"} %} macro {{http_method.id}}(path, controller, method = :handle) @@ -8,33 +10,35 @@ module Invidious::Routing end {% end %} -end -macro define_user_routes - # User login/out - Invidious::Routing.get "/login", Invidious::Routes::Login, :login_page - Invidious::Routing.post "/login", Invidious::Routes::Login, :login - Invidious::Routing.post "/signout", Invidious::Routes::Login, :signout - Invidious::Routing.get "/Captcha", Invidious::Routes::Login, :captcha - - # User preferences - Invidious::Routing.get "/preferences", Invidious::Routes::PreferencesRoute, :show - Invidious::Routing.post "/preferences", Invidious::Routes::PreferencesRoute, :update - Invidious::Routing.get "/toggle_theme", Invidious::Routes::PreferencesRoute, :toggle_theme - Invidious::Routing.get "/data_control", Invidious::Routes::PreferencesRoute, :data_control - Invidious::Routing.post "/data_control", Invidious::Routes::PreferencesRoute, :update_data_control - - # User account management - Invidious::Routing.get "/change_password", Invidious::Routes::Account, :get_change_password - Invidious::Routing.post "/change_password", Invidious::Routes::Account, :post_change_password - Invidious::Routing.get "/delete_account", Invidious::Routes::Account, :get_delete - Invidious::Routing.post "/delete_account", Invidious::Routes::Account, :post_delete - Invidious::Routing.get "/clear_watch_history", Invidious::Routes::Account, :get_clear_history - Invidious::Routing.post "/clear_watch_history", Invidious::Routes::Account, :post_clear_history - Invidious::Routing.get "/authorize_token", Invidious::Routes::Account, :get_authorize_token - Invidious::Routing.post "/authorize_token", Invidious::Routes::Account, :post_authorize_token - Invidious::Routing.get "/token_manager", Invidious::Routes::Account, :token_manager - Invidious::Routing.post "/token_ajax", Invidious::Routes::Account, :token_ajax + def register_user_routes + # User login/out + get "/login", Routes::Login, :login_page + post "/login", Routes::Login, :login + post "/signout", Routes::Login, :signout + get "/Captcha", Routes::Login, :captcha + + # User preferences + get "/preferences", Routes::PreferencesRoute, :show + post "/preferences", Routes::PreferencesRoute, :update + get "/toggle_theme", Routes::PreferencesRoute, :toggle_theme + get "/data_control", Routes::PreferencesRoute, :data_control + post "/data_control", Routes::PreferencesRoute, :update_data_control + + # User account management + get "/change_password", Routes::Account, :get_change_password + post "/change_password", Routes::Account, :post_change_password + get "/delete_account", Routes::Account, :get_delete + post "/delete_account", Routes::Account, :post_delete + get "/clear_watch_history", Routes::Account, :get_clear_history + post "/clear_watch_history", Routes::Account, :post_clear_history + get "/authorize_token", Routes::Account, :get_authorize_token + post "/authorize_token", Routes::Account, :post_authorize_token + get "/token_manager", Routes::Account, :token_manager + post "/token_ajax", Routes::Account, :token_ajax + post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription + get "/subscription_manager", Routes::Subscriptions, :subscription_manager + end end macro define_v1_api_routes -- cgit v1.2.3 From 176247091d5df6fe7d9b772ef3e1ff09d3bc9c1c Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 00:07:47 +0200 Subject: routing: register API routes with a function, rather than a macro --- src/invidious.cr | 2 +- src/invidious/routing.cr | 143 +++++++++++++++++++++++++---------------------- 2 files changed, 76 insertions(+), 69 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 91bf6935..1188710f 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -420,7 +420,7 @@ Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails # API routes (macro) -define_v1_api_routes() +Invidious::Routing.register_api_v1_routes # Video playback (macros) define_api_manifest_routes() diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 23119e62..9e8ce34d 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -11,6 +11,10 @@ module Invidious::Routing {% end %} + # ------------------- + # Invidious routes + # ------------------- + def register_user_routes # User login/out get "/login", Routes::Login, :login_page @@ -39,75 +43,78 @@ module Invidious::Routing post "/subscription_ajax", Routes::Subscriptions, :toggle_subscription get "/subscription_manager", Routes::Subscriptions, :subscription_manager end -end - -macro define_v1_api_routes - {{namespace = Invidious::Routes::API::V1}} - # Videos - Invidious::Routing.get "/api/v1/videos/:id", {{namespace}}::Videos, :videos - Invidious::Routing.get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards - Invidious::Routing.get "/api/v1/captions/:id", {{namespace}}::Videos, :captions - Invidious::Routing.get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations - Invidious::Routing.get "/api/v1/comments/:id", {{namespace}}::Videos, :comments - - # Feeds - Invidious::Routing.get "/api/v1/trending", {{namespace}}::Feeds, :trending - Invidious::Routing.get "/api/v1/popular", {{namespace}}::Feeds, :popular - - # Channels - Invidious::Routing.get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home - {% for route in {"videos", "latest", "playlists", "community", "search"} %} - Invidious::Routing.get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} - Invidious::Routing.get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} - {% end %} - - # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community - Invidious::Routing.get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect - Invidious::Routing.get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect - - - # Search - Invidious::Routing.get "/api/v1/search", {{namespace}}::Search, :search - Invidious::Routing.get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions - - # Authenticated - - # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr - # - # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - - Invidious::Routing.get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences - Invidious::Routing.post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences - Invidious::Routing.get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed - - Invidious::Routing.get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions - Invidious::Routing.post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel - Invidious::Routing.delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel - - - Invidious::Routing.get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists - Invidious::Routing.post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist - Invidious::Routing.patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute - Invidious::Routing.delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist - - - Invidious::Routing.post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist - Invidious::Routing.delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist - - Invidious::Routing.get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens - Invidious::Routing.post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token - Invidious::Routing.post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token - - Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications - - # Misc - Invidious::Routing.get "/api/v1/stats", {{namespace}}::Misc, :stats - Invidious::Routing.get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist - Invidious::Routing.get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist - Invidious::Routing.get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes + # ------------------- + # API routes + # ------------------- + + def register_api_v1_routes + {% begin %} + {{namespace = Routes::API::V1}} + + # Videos + get "/api/v1/videos/:id", {{namespace}}::Videos, :videos + get "/api/v1/storyboards/:id", {{namespace}}::Videos, :storyboards + get "/api/v1/captions/:id", {{namespace}}::Videos, :captions + get "/api/v1/annotations/:id", {{namespace}}::Videos, :annotations + get "/api/v1/comments/:id", {{namespace}}::Videos, :comments + + # Feeds + get "/api/v1/trending", {{namespace}}::Feeds, :trending + get "/api/v1/popular", {{namespace}}::Feeds, :popular + + # Channels + get "/api/v1/channels/:ucid", {{namespace}}::Channels, :home + {% for route in {"videos", "latest", "playlists", "community", "search"} %} + get "/api/v1/channels/#{{{route}}}/:ucid", {{namespace}}::Channels, :{{route}} + get "/api/v1/channels/:ucid/#{{{route}}}", {{namespace}}::Channels, :{{route}} + {% end %} + + # 301 redirects to new /api/v1/channels/community/:ucid and /:ucid/community + get "/api/v1/channels/comments/:ucid", {{namespace}}::Channels, :channel_comments_redirect + get "/api/v1/channels/:ucid/comments", {{namespace}}::Channels, :channel_comments_redirect + + # Search + get "/api/v1/search", {{namespace}}::Search, :search + get "/api/v1/search/suggestions", {{namespace}}::Search, :search_suggestions + + # Authenticated + + # The notification APIs cannot be extracted yet! They require the *local* notifications constant defined in invidious.cr + # + # Invidious::Routing.get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + # Invidious::Routing.post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + + get "/api/v1/auth/preferences", {{namespace}}::Authenticated, :get_preferences + post "/api/v1/auth/preferences", {{namespace}}::Authenticated, :set_preferences + + get "/api/v1/auth/feed", {{namespace}}::Authenticated, :feed + + get "/api/v1/auth/subscriptions", {{namespace}}::Authenticated, :get_subscriptions + post "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :subscribe_channel + delete "/api/v1/auth/subscriptions/:ucid", {{namespace}}::Authenticated, :unsubscribe_channel + + get "/api/v1/auth/playlists", {{namespace}}::Authenticated, :list_playlists + post "/api/v1/auth/playlists", {{namespace}}::Authenticated, :create_playlist + patch "/api/v1/auth/playlists/:plid",{{namespace}}:: Authenticated, :update_playlist_attribute + delete "/api/v1/auth/playlists/:plid", {{namespace}}::Authenticated, :delete_playlist + post "/api/v1/auth/playlists/:plid/videos", {{namespace}}::Authenticated, :insert_video_into_playlist + delete "/api/v1/auth/playlists/:plid/videos/:index", {{namespace}}::Authenticated, :delete_video_in_playlist + + get "/api/v1/auth/tokens", {{namespace}}::Authenticated, :get_tokens + post "/api/v1/auth/tokens/register", {{namespace}}::Authenticated, :register_token + post "/api/v1/auth/tokens/unregister", {{namespace}}::Authenticated, :unregister_token + + get "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + post "/api/v1/auth/notifications", {{namespace}}::Authenticated, :notifications + + # Misc + get "/api/v1/stats", {{namespace}}::Misc, :stats + get "/api/v1/playlists/:plid", {{namespace}}::Misc, :get_playlist + get "/api/v1/auth/playlists/:plid", {{namespace}}::Misc, :get_playlist + get "/api/v1/mixes/:rdid", {{namespace}}::Misc, :mixes + {% end %} + end end macro define_api_manifest_routes -- cgit v1.2.3 From 389ae7a57395f1b3fbf540deebbad73d0674e715 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 00:09:58 +0200 Subject: routing: register playback routes with a function, rather than a macro --- src/invidious.cr | 4 ++-- src/invidious/routing.cr | 50 ++++++++++++++++++++++++++---------------------- 2 files changed, 29 insertions(+), 25 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 1188710f..f244cea5 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -423,8 +423,8 @@ Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails Invidious::Routing.register_api_v1_routes # Video playback (macros) -define_api_manifest_routes() -define_video_playback_routes() +Invidious::Routing.register_api_manifest_routes +Invidious::Routing.register_video_playback_routes error 404 do |env| if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 9e8ce34d..25cbfa48 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -44,6 +44,33 @@ module Invidious::Routing get "/subscription_manager", Routes::Subscriptions, :subscription_manager end + # ------------------- + # Youtube routes + # ------------------- + + def register_api_manifest_routes + get "/api/manifest/dash/id/:id", Routes::API::Manifest, :get_dash_video_id + + get "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :get_dash_video_playback + get "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :get_dash_video_playback_greedy + + options "/api/manifest/dash/id/videoplayback", Routes::API::Manifest, :options_dash_video_playback + options "/api/manifest/dash/id/videoplayback/*", Routes::API::Manifest, :options_dash_video_playback + + get "/api/manifest/hls_playlist/*", Routes::API::Manifest, :get_hls_playlist + get "/api/manifest/hls_variant/*", Routes::API::Manifest, :get_hls_variant + end + + def register_video_playback_routes + get "/videoplayback", Routes::VideoPlayback, :get_video_playback + get "/videoplayback/*", Routes::VideoPlayback, :get_video_playback_greedy + + options "/videoplayback", Routes::VideoPlayback, :options_video_playback + options "/videoplayback/*", Routes::VideoPlayback, :options_video_playback + + get "/latest_version", Routes::VideoPlayback, :latest_version + end + # ------------------- # API routes # ------------------- @@ -116,26 +143,3 @@ module Invidious::Routing {% end %} end end - -macro define_api_manifest_routes - Invidious::Routing.get "/api/manifest/dash/id/:id", Invidious::Routes::API::Manifest, :get_dash_video_id - - Invidious::Routing.get "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :get_dash_video_playback - Invidious::Routing.get "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :get_dash_video_playback_greedy - - Invidious::Routing.options "/api/manifest/dash/id/videoplayback", Invidious::Routes::API::Manifest, :options_dash_video_playback - Invidious::Routing.options "/api/manifest/dash/id/videoplayback/*", Invidious::Routes::API::Manifest, :options_dash_video_playback - - Invidious::Routing.get "/api/manifest/hls_playlist/*", Invidious::Routes::API::Manifest, :get_hls_playlist - Invidious::Routing.get "/api/manifest/hls_variant/*", Invidious::Routes::API::Manifest, :get_hls_variant -end - -macro define_video_playback_routes - Invidious::Routing.get "/videoplayback", Invidious::Routes::VideoPlayback, :get_video_playback - Invidious::Routing.get "/videoplayback/*", Invidious::Routes::VideoPlayback, :get_video_playback_greedy - - Invidious::Routing.options "/videoplayback", Invidious::Routes::VideoPlayback, :options_video_playback - Invidious::Routing.options "/videoplayback/*", Invidious::Routes::VideoPlayback, :options_video_playback - - Invidious::Routing.get "/latest_version", Invidious::Routes::VideoPlayback, :latest_version -end -- cgit v1.2.3 From 3ac4390d11d7eecbd49e3db79376942e8706783b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 00:14:26 +0200 Subject: routing: move channel routes registration to Invidious::Routing --- src/invidious.cr | 21 +-------------------- src/invidious/routing.cr | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 20 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index f244cea5..969804a6 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -334,26 +334,7 @@ end Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses - Invidious::Routing.get "/channel/:ucid", Invidious::Routes::Channels, :home - Invidious::Routing.get "/channel/:ucid/home", Invidious::Routes::Channels, :home - Invidious::Routing.get "/channel/:ucid/videos", Invidious::Routes::Channels, :videos - Invidious::Routing.get "/channel/:ucid/playlists", Invidious::Routes::Channels, :playlists - Invidious::Routing.get "/channel/:ucid/community", Invidious::Routes::Channels, :community - Invidious::Routing.get "/channel/:ucid/about", Invidious::Routes::Channels, :about - Invidious::Routing.get "/channel/:ucid/live", Invidious::Routes::Channels, :live - Invidious::Routing.get "/user/:user/live", Invidious::Routes::Channels, :live - Invidious::Routing.get "/c/:user/live", Invidious::Routes::Channels, :live - - ["", "/videos", "/playlists", "/community", "/about"].each do |path| - # /c/LinusTechTips - Invidious::Routing.get "/c/:user#{path}", Invidious::Routes::Channels, :brand_redirect - # /user/linustechtips | Not always the same as /c/ - Invidious::Routing.get "/user/:user#{path}", Invidious::Routes::Channels, :brand_redirect - # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow - Invidious::Routing.get "/attribution_link#{path}", Invidious::Routes::Channels, :brand_redirect - # /profile?user=linustechtips - Invidious::Routing.get "/profile/#{path}", Invidious::Routes::Channels, :profile - end + Invidious::Routing.register_channel_routes Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle Invidious::Routing.post "/watch_ajax", Invidious::Routes::Watch, :mark_watched diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 25cbfa48..203aa024 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -48,6 +48,29 @@ module Invidious::Routing # Youtube routes # ------------------- + def register_channel_routes + get "/channel/:ucid", Routes::Channels, :home + get "/channel/:ucid/home", Routes::Channels, :home + get "/channel/:ucid/videos", Routes::Channels, :videos + get "/channel/:ucid/playlists", Routes::Channels, :playlists + get "/channel/:ucid/community", Routes::Channels, :community + get "/channel/:ucid/about", Routes::Channels, :about + get "/channel/:ucid/live", Routes::Channels, :live + get "/user/:user/live", Routes::Channels, :live + get "/c/:user/live", Routes::Channels, :live + + ["", "/videos", "/playlists", "/community", "/about"].each do |path| + # /c/LinusTechTips + get "/c/:user#{path}", Routes::Channels, :brand_redirect + # /user/linustechtips | Not always the same as /c/ + get "/user/:user#{path}", Routes::Channels, :brand_redirect + # /attribution_link?a=anything&u=/channel/UCZYTClx2T1of7BRZ86-8fow + get "/attribution_link#{path}", Routes::Channels, :brand_redirect + # /profile?user=linustechtips + get "/profile/#{path}", Routes::Channels, :profile + end + end + def register_api_manifest_routes get "/api/manifest/dash/id/:id", Routes::API::Manifest, :get_dash_video_id -- cgit v1.2.3 From e2532de766bec9a2e967d551776823b83f44e995 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 00:20:04 +0200 Subject: routing: move image proxy routes registration to Invidious::Routing --- src/invidious.cr | 7 +------ src/invidious/routing.cr | 9 +++++++++ 2 files changed, 10 insertions(+), 6 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 969804a6..9daf5380 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -393,12 +393,7 @@ end Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify {% end %} -Invidious::Routing.get "/ggpht/*", Invidious::Routes::Images, :ggpht -Invidious::Routing.options "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :options_storyboard -Invidious::Routing.get "/sb/:authority/:id/:storyboard/:index", Invidious::Routes::Images, :get_storyboard -Invidious::Routing.get "/s_p/:id/:name", Invidious::Routes::Images, :s_p_image -Invidious::Routing.get "/yts/img/:name", Invidious::Routes::Images, :yts_image -Invidious::Routing.get "/vi/:id/:name", Invidious::Routes::Images, :thumbnails +Invidious::Routing.register_image_routes # API routes (macro) Invidious::Routing.register_api_v1_routes diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 203aa024..45ae7c6b 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -94,6 +94,15 @@ module Invidious::Routing get "/latest_version", Routes::VideoPlayback, :latest_version end + def register_image_routes + get "/ggpht/*", Routes::Images, :ggpht + options "/sb/:authority/:id/:storyboard/:index", Routes::Images, :options_storyboard + get "/sb/:authority/:id/:storyboard/:index", Routes::Images, :get_storyboard + get "/s_p/:id/:name", Routes::Images, :s_p_image + get "/yts/img/:name", Routes::Images, :yts_image + get "/vi/:id/:name", Routes::Images, :thumbnails + end + # ------------------- # API routes # ------------------- -- cgit v1.2.3 From 906466d7fb31686b208f04172dbd6ecaa9e1f1c6 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 00:22:40 +0200 Subject: routing: move watch/embed routes registration to Invidious::Routing --- src/invidious.cr | 17 ++--------------- src/invidious/routing.cr | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 15 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 9daf5380..b9c88114 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -333,23 +333,10 @@ end Invidious::Routing.get "/", Invidious::Routes::Misc, :home Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses - - Invidious::Routing.register_channel_routes - - Invidious::Routing.get "/watch", Invidious::Routes::Watch, :handle - Invidious::Routing.post "/watch_ajax", Invidious::Routes::Watch, :mark_watched - Invidious::Routing.get "/watch/:id", Invidious::Routes::Watch, :redirect - Invidious::Routing.get "/shorts/:id", Invidious::Routes::Watch, :redirect - Invidious::Routing.get "/clip/:clip", Invidious::Routes::Watch, :clip - Invidious::Routing.get "/w/:id", Invidious::Routes::Watch, :redirect - Invidious::Routing.get "/v/:id", Invidious::Routes::Watch, :redirect - Invidious::Routing.get "/e/:id", Invidious::Routes::Watch, :redirect Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect - Invidious::Routing.post "/download", Invidious::Routes::Watch, :download - - Invidious::Routing.get "/embed/", Invidious::Routes::Embed, :redirect - Invidious::Routing.get "/embed/:id", Invidious::Routes::Embed, :show + Invidious::Routing.register_channel_routes + Invidious::Routing.register_watch_routes Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 45ae7c6b..4f6db78c 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -71,6 +71,22 @@ module Invidious::Routing end end + def register_watch_routes + get "/watch", Routes::Watch, :handle + post "/watch_ajax", Routes::Watch, :mark_watched + get "/watch/:id", Routes::Watch, :redirect + get "/shorts/:id", Routes::Watch, :redirect + get "/clip/:clip", Routes::Watch, :clip + get "/w/:id", Routes::Watch, :redirect + get "/v/:id", Routes::Watch, :redirect + get "/e/:id", Routes::Watch, :redirect + + post "/download", Routes::Watch, :download + + get "/embed/", Routes::Embed, :redirect + get "/embed/:id", Routes::Embed, :show + end + def register_api_manifest_routes get "/api/manifest/dash/id/:id", Routes::API::Manifest, :get_dash_video_id -- cgit v1.2.3 From 5503914abe28eefdc89ca9a4762cc434a351f378 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 00:26:41 +0200 Subject: routing: move playlist routes registration to Invidious::Routing --- src/invidious.cr | 14 ++------------ src/invidious/routing.cr | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 12 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index b9c88114..f134886f 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -338,18 +338,8 @@ end Invidious::Routing.register_channel_routes Invidious::Routing.register_watch_routes - Invidious::Routing.get "/create_playlist", Invidious::Routes::Playlists, :new - Invidious::Routing.post "/create_playlist", Invidious::Routes::Playlists, :create - Invidious::Routing.get "/subscribe_playlist", Invidious::Routes::Playlists, :subscribe - Invidious::Routing.get "/delete_playlist", Invidious::Routes::Playlists, :delete_page - Invidious::Routing.post "/delete_playlist", Invidious::Routes::Playlists, :delete - Invidious::Routing.get "/edit_playlist", Invidious::Routes::Playlists, :edit - Invidious::Routing.post "/edit_playlist", Invidious::Routes::Playlists, :update - Invidious::Routing.get "/add_playlist_items", Invidious::Routes::Playlists, :add_playlist_items_page - Invidious::Routing.post "/playlist_ajax", Invidious::Routes::Playlists, :playlist_ajax - Invidious::Routing.get "/playlist", Invidious::Routes::Playlists, :show - Invidious::Routing.get "/mix", Invidious::Routes::Playlists, :mix - Invidious::Routing.get "/watch_videos", Invidious::Routes::Playlists, :watch_videos + Invidious::Routing.register_iv_playlist_routes + Invidious::Routing.register_yt_playlist_routes Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch Invidious::Routing.get "/results", Invidious::Routes::Search, :results diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 4f6db78c..4074ef18 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -44,6 +44,18 @@ module Invidious::Routing get "/subscription_manager", Routes::Subscriptions, :subscription_manager end + def register_iv_playlist_routes + get "/create_playlist", Routes::Playlists, :new + post "/create_playlist", Routes::Playlists, :create + get "/subscribe_playlist", Routes::Playlists, :subscribe + get "/delete_playlist", Routes::Playlists, :delete_page + post "/delete_playlist", Routes::Playlists, :delete + get "/edit_playlist", Routes::Playlists, :edit + post "/edit_playlist", Routes::Playlists, :update + get "/add_playlist_items", Routes::Playlists, :add_playlist_items_page + post "/playlist_ajax", Routes::Playlists, :playlist_ajax + end + # ------------------- # Youtube routes # ------------------- @@ -87,6 +99,12 @@ module Invidious::Routing get "/embed/:id", Routes::Embed, :show end + def register_yt_playlist_routes + get "/playlist", Routes::Playlists, :show + get "/mix", Routes::Playlists, :mix + get "/watch_videos", Routes::Playlists, :watch_videos + end + def register_api_manifest_routes get "/api/manifest/dash/id/:id", Routes::API::Manifest, :get_dash_video_id -- cgit v1.2.3 From 0a4d793556e89e48b1a4caceaf8b8730b4b69d73 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 00:31:15 +0200 Subject: routing: move search routes registration to Invidious::Routing --- src/invidious.cr | 5 +---- src/invidious/routing.cr | 11 +++++++++++ 2 files changed, 12 insertions(+), 4 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index f134886f..e880db19 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -341,10 +341,7 @@ end Invidious::Routing.register_iv_playlist_routes Invidious::Routing.register_yt_playlist_routes - Invidious::Routing.get "/opensearch.xml", Invidious::Routes::Search, :opensearch - Invidious::Routing.get "/results", Invidious::Routes::Search, :results - Invidious::Routing.get "/search", Invidious::Routes::Search, :search - Invidious::Routing.get "/hashtag/:hashtag", Invidious::Routes::Search, :hashtag + Invidious::Routing.register_search_routes # User routes Invidious::Routing.register_user_routes diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 4074ef18..828deaf9 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -105,6 +105,17 @@ module Invidious::Routing get "/watch_videos", Routes::Playlists, :watch_videos end + def register_search_routes + get "/opensearch.xml", Routes::Search, :opensearch + get "/results", Routes::Search, :results + get "/search", Routes::Search, :search + get "/hashtag/:hashtag", Routes::Search, :hashtag + end + + # ------------------- + # Media proxy routes + # ------------------- + def register_api_manifest_routes get "/api/manifest/dash/id/:id", Routes::API::Manifest, :get_dash_video_id -- cgit v1.2.3 From 223e74569aa3355857ee37f84b0eac2a8dd24b3d Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 00:44:21 +0200 Subject: routing: move feed routes registration to Invidious::Routing --- src/invidious.cr | 14 +------------- src/invidious/routing.cr | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 13 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index e880db19..4a3b28b1 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -346,19 +346,7 @@ end # User routes Invidious::Routing.register_user_routes - # Feeds - Invidious::Routing.get "/view_all_playlists", Invidious::Routes::Feeds, :view_all_playlists_redirect - Invidious::Routing.get "/feed/playlists", Invidious::Routes::Feeds, :playlists - Invidious::Routing.get "/feed/popular", Invidious::Routes::Feeds, :popular - Invidious::Routing.get "/feed/trending", Invidious::Routes::Feeds, :trending - Invidious::Routing.get "/feed/subscriptions", Invidious::Routes::Feeds, :subscriptions - Invidious::Routing.get "/feed/history", Invidious::Routes::Feeds, :history - - # RSS Feeds - Invidious::Routing.get "/feed/channel/:ucid", Invidious::Routes::Feeds, :rss_channel - Invidious::Routing.get "/feed/private", Invidious::Routes::Feeds, :rss_private - Invidious::Routing.get "/feed/playlist/:plid", Invidious::Routes::Feeds, :rss_playlist - Invidious::Routing.get "/feeds/videos.xml", Invidious::Routes::Feeds, :rss_videos + Invidious::Routing.register_feed_routes # Support push notifications via PubSubHubbub Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 828deaf9..e9657bba 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -56,6 +56,22 @@ module Invidious::Routing post "/playlist_ajax", Routes::Playlists, :playlist_ajax end + def register_feed_routes + # Feeds + get "/view_all_playlists", Routes::Feeds, :view_all_playlists_redirect + get "/feed/playlists", Routes::Feeds, :playlists + get "/feed/popular", Routes::Feeds, :popular + get "/feed/trending", Routes::Feeds, :trending + get "/feed/subscriptions", Routes::Feeds, :subscriptions + get "/feed/history", Routes::Feeds, :history + + # RSS Feeds + get "/feed/channel/:ucid", Routes::Feeds, :rss_channel + get "/feed/private", Routes::Feeds, :rss_private + get "/feed/playlist/:plid", Routes::Feeds, :rss_playlist + get "/feeds/videos.xml", Routes::Feeds, :rss_videos + end + # ------------------- # Youtube routes # ------------------- -- cgit v1.2.3 From 1e25894f7ec37044698f9fddf60813e0199b921b Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 00:48:09 +0200 Subject: routing: move the remaining routes registration to a wrapper function --- src/invidious.cr | 35 +---------------------------------- src/invidious/routing.cr | 31 +++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 34 deletions(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 4a3b28b1..95e4c225 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -329,40 +329,7 @@ before_all do |env| env.set "current_page", URI.encode_www_form(current_page) end -{% unless flag?(:api_only) %} - Invidious::Routing.get "/", Invidious::Routes::Misc, :home - Invidious::Routing.get "/privacy", Invidious::Routes::Misc, :privacy - Invidious::Routing.get "/licenses", Invidious::Routes::Misc, :licenses - Invidious::Routing.get "/redirect", Invidious::Routes::Misc, :cross_instance_redirect - - Invidious::Routing.register_channel_routes - Invidious::Routing.register_watch_routes - - Invidious::Routing.register_iv_playlist_routes - Invidious::Routing.register_yt_playlist_routes - - Invidious::Routing.register_search_routes - - # User routes - Invidious::Routing.register_user_routes - - Invidious::Routing.register_feed_routes - - # Support push notifications via PubSubHubbub - Invidious::Routing.get "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_get - Invidious::Routing.post "/feed/webhook/:token", Invidious::Routes::Feeds, :push_notifications_post - - Invidious::Routing.get "/modify_notifications", Invidious::Routes::Notifications, :modify -{% end %} - -Invidious::Routing.register_image_routes - -# API routes (macro) -Invidious::Routing.register_api_v1_routes - -# Video playback (macros) -Invidious::Routing.register_api_manifest_routes -Invidious::Routing.register_video_playback_routes +Invidious::Routing.register_all error 404 do |env| if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index e9657bba..8084b3e4 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -11,6 +11,37 @@ module Invidious::Routing {% end %} + def register_all + {% unless flag?(:api_only) %} + get "/", Routes::Misc, :home + get "/privacy", Routes::Misc, :privacy + get "/licenses", Routes::Misc, :licenses + get "/redirect", Routes::Misc, :cross_instance_redirect + + self.register_channel_routes + self.register_watch_routes + + self.register_iv_playlist_routes + self.register_yt_playlist_routes + + self.register_search_routes + + self.register_user_routes + self.register_feed_routes + + # Support push notifications via PubSubHubbub + get "/feed/webhook/:token", Routes::Feeds, :push_notifications_get + post "/feed/webhook/:token", Routes::Feeds, :push_notifications_post + + get "/modify_notifications", Routes::Notifications, :modify + {% end %} + + self.register_image_routes + self.register_api_v1_routes + self.register_api_manifest_routes + self.register_video_playback_routes + end + # ------------------- # Invidious routes # ------------------- -- cgit v1.2.3 From 870350fd612008a3694159ef933831943fca68b4 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 00:52:09 +0200 Subject: routes: move before_all logic to its own module --- src/invidious.cr | 153 ++----------------------------------- src/invidious/routes/before_all.cr | 152 ++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+), 148 deletions(-) create mode 100644 src/invidious/routes/before_all.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 95e4c225..4a3b0003 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -178,155 +178,10 @@ def popular_videos Invidious::Jobs::PullPopularVideosJob::POPULAR_VIDEOS.get end -before_all do |env| - preferences = Preferences.from_json("{}") - - begin - if prefs_cookie = env.request.cookies["PREFS"]? - preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value)) - else - if language_header = env.request.headers["Accept-Language"]? - if language = ANG.language_negotiator.best(language_header, LOCALES.keys) - preferences.locale = language.header - end - end - end - rescue - preferences = Preferences.from_json("{}") - end - - env.set "preferences", preferences - env.response.headers["X-XSS-Protection"] = "1; mode=block" - env.response.headers["X-Content-Type-Options"] = "nosniff" - - # Allow media resources to be loaded from google servers - # TODO: check if *.youtube.com can be removed - if CONFIG.disabled?("local") || !preferences.local - extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443" - else - extra_media_csp = "" - end - - # Only allow the pages at /embed/* to be embedded - if env.request.resource.starts_with?("/embed") - frame_ancestors = "'self' http: https:" - else - frame_ancestors = "'none'" - end - - # TODO: Remove style-src's 'unsafe-inline', requires to remove all - # inline styles (, style=" [..] ") - env.response.headers["Content-Security-Policy"] = { - "default-src 'none'", - "script-src 'self'", - "style-src 'self' 'unsafe-inline'", - "img-src 'self' data:", - "font-src 'self' data:", - "connect-src 'self'", - "manifest-src 'self'", - "media-src 'self' blob:" + extra_media_csp, - "child-src 'self' blob:", - "frame-src 'self'", - "frame-ancestors " + frame_ancestors, - }.join("; ") - - env.response.headers["Referrer-Policy"] = "same-origin" - - # Ask the chrom*-based browsers to disable FLoC - # See: https://blog.runcloud.io/google-floc/ - env.response.headers["Permissions-Policy"] = "interest-cohort=()" - - if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts - env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" - end - - next if { - "/sb/", - "/vi/", - "/s_p/", - "/yts/", - "/ggpht/", - "/api/manifest/", - "/videoplayback", - "/latest_version", - "/download", - }.any? { |r| env.request.resource.starts_with? r } - - if env.request.cookies.has_key? "SID" - sid = env.request.cookies["SID"].value - - if sid.starts_with? "v1:" - raise "Cannot use token as SID" - end +# Routing - # Invidious users only have SID - if !env.request.cookies.has_key? "SSID" - if email = Invidious::Database::SessionIDs.select_email(sid) - user = Invidious::Database::Users.select!(email: email) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, 1.week) - - preferences = user.preferences - env.set "preferences", preferences - - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - end - else - headers = HTTP::Headers.new - headers["Cookie"] = env.request.headers["Cookie"] - - begin - user, sid = get_user(sid, headers, false) - csrf_token = generate_response(sid, { - ":authorize_token", - ":playlist_ajax", - ":signout", - ":subscription_ajax", - ":token_ajax", - ":watch_ajax", - }, HMAC_KEY, 1.week) - - preferences = user.preferences - env.set "preferences", preferences - - env.set "sid", sid - env.set "csrf_token", csrf_token - env.set "user", user - rescue ex - end - end - 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 - preferences.thin_mode = thin_mode - preferences.locale = locale - env.set "preferences", preferences - - current_page = env.request.path - if env.request.query - query = HTTP::Params.parse(env.request.query.not_nil!) - - if query["referer"]? - query["referer"] = get_referer(env, "/") - end - - current_page += "?#{query}" - end - - env.set "current_page", URI.encode_www_form(current_page) +before_all do |env| + Invidious::Routes::BeforeAll.handle(env) end Invidious::Routing.register_all @@ -386,6 +241,8 @@ static_headers do |response| response.headers.add("Cache-Control", "max-age=2629800") end +# Init Kemal + public_folder "assets" Kemal.config.powered_by_header = false diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr new file mode 100644 index 00000000..8e2a253f --- /dev/null +++ b/src/invidious/routes/before_all.cr @@ -0,0 +1,152 @@ +module Invidious::Routes::BeforeAll + def self.handle(env) + preferences = Preferences.from_json("{}") + + begin + if prefs_cookie = env.request.cookies["PREFS"]? + preferences = Preferences.from_json(URI.decode_www_form(prefs_cookie.value)) + else + if language_header = env.request.headers["Accept-Language"]? + if language = ANG.language_negotiator.best(language_header, LOCALES.keys) + preferences.locale = language.header + end + end + end + rescue + preferences = Preferences.from_json("{}") + end + + env.set "preferences", preferences + env.response.headers["X-XSS-Protection"] = "1; mode=block" + env.response.headers["X-Content-Type-Options"] = "nosniff" + + # Allow media resources to be loaded from google servers + # TODO: check if *.youtube.com can be removed + if CONFIG.disabled?("local") || !preferences.local + extra_media_csp = " https://*.googlevideo.com:443 https://*.youtube.com:443" + else + extra_media_csp = "" + end + + # Only allow the pages at /embed/* to be embedded + if env.request.resource.starts_with?("/embed") + frame_ancestors = "'self' http: https:" + else + frame_ancestors = "'none'" + end + + # TODO: Remove style-src's 'unsafe-inline', requires to remove all + # inline styles (, style=" [..] ") + env.response.headers["Content-Security-Policy"] = { + "default-src 'none'", + "script-src 'self'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self' data:", + "connect-src 'self'", + "manifest-src 'self'", + "media-src 'self' blob:" + extra_media_csp, + "child-src 'self' blob:", + "frame-src 'self'", + "frame-ancestors " + frame_ancestors, + }.join("; ") + + env.response.headers["Referrer-Policy"] = "same-origin" + + # Ask the chrom*-based browsers to disable FLoC + # See: https://blog.runcloud.io/google-floc/ + env.response.headers["Permissions-Policy"] = "interest-cohort=()" + + if (Kemal.config.ssl || CONFIG.https_only) && CONFIG.hsts + env.response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload" + end + + return if { + "/sb/", + "/vi/", + "/s_p/", + "/yts/", + "/ggpht/", + "/api/manifest/", + "/videoplayback", + "/latest_version", + "/download", + }.any? { |r| env.request.resource.starts_with? r } + + if env.request.cookies.has_key? "SID" + sid = env.request.cookies["SID"].value + + if sid.starts_with? "v1:" + raise "Cannot use token as SID" + end + + # Invidious users only have SID + if !env.request.cookies.has_key? "SSID" + if email = Invidious::Database::SessionIDs.select_email(sid) + user = Invidious::Database::Users.select!(email: email) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, 1.week) + + preferences = user.preferences + env.set "preferences", preferences + + env.set "sid", sid + env.set "csrf_token", csrf_token + env.set "user", user + end + else + headers = HTTP::Headers.new + headers["Cookie"] = env.request.headers["Cookie"] + + begin + user, sid = get_user(sid, headers, false) + csrf_token = generate_response(sid, { + ":authorize_token", + ":playlist_ajax", + ":signout", + ":subscription_ajax", + ":token_ajax", + ":watch_ajax", + }, HMAC_KEY, 1.week) + + preferences = user.preferences + env.set "preferences", preferences + + env.set "sid", sid + env.set "csrf_token", csrf_token + env.set "user", user + rescue ex + end + end + 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 + preferences.thin_mode = thin_mode + preferences.locale = locale + env.set "preferences", preferences + + current_page = env.request.path + if env.request.query + query = HTTP::Params.parse(env.request.query.not_nil!) + + if query["referer"]? + query["referer"] = get_referer(env, "/") + end + + current_page += "?#{query}" + end + + env.set "current_page", URI.encode_www_form(current_page) + end +end -- cgit v1.2.3 From 88ea794fdb6222011020b6fc778f6cd5da70484a Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 01:00:44 +0200 Subject: routes: move error 404 logic to its own module --- src/invidious.cr | 44 +-------------------------------------- src/invidious/routes/errors.cr | 47 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 43 deletions(-) create mode 100644 src/invidious/routes/errors.cr (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index 4a3b0003..aff879e3 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -187,49 +187,7 @@ end Invidious::Routing.register_all error 404 do |env| - if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) - item = md["id"] - - # Check if item is branding URL e.g. https://youtube.com/gaming - response = YT_POOL.client &.get("/#{item}") - - if response.status_code == 301 - response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target) - end - - if response.body.empty? - env.response.headers["Location"] = "/" - halt env, status_code: 302 - end - - html = XML.parse_html(response.body) - ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] - - if ucid - env.response.headers["Location"] = "/channel/#{ucid}" - halt env, status_code: 302 - end - - params = [] of String - env.params.query.each do |k, v| - params << "#{k}=#{v}" - end - params = params.join("&") - - url = "/watch?v=#{item}" - if !params.empty? - url += "&#{params}" - end - - # Check if item is video ID - if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404 - env.response.headers["Location"] = url - halt env, status_code: 302 - end - end - - env.response.headers["Location"] = "/" - halt env, status_code: 302 + Invidious::Routes::ErrorRoutes.error_404(env) end error 500 do |env, ex| diff --git a/src/invidious/routes/errors.cr b/src/invidious/routes/errors.cr new file mode 100644 index 00000000..b138b562 --- /dev/null +++ b/src/invidious/routes/errors.cr @@ -0,0 +1,47 @@ +module Invidious::Routes::ErrorRoutes + def self.error_404(env) + if md = env.request.path.match(/^\/(?([a-zA-Z0-9_-]{11})|(\w+))$/) + item = md["id"] + + # Check if item is branding URL e.g. https://youtube.com/gaming + response = YT_POOL.client &.get("/#{item}") + + if response.status_code == 301 + response = YT_POOL.client &.get(URI.parse(response.headers["Location"]).request_target) + end + + if response.body.empty? + env.response.headers["Location"] = "/" + haltf env, status_code: 302 + end + + html = XML.parse_html(response.body) + ucid = html.xpath_node(%q(//link[@rel="canonical"])).try &.["href"].split("/")[-1] + + if ucid + env.response.headers["Location"] = "/channel/#{ucid}" + haltf env, status_code: 302 + end + + params = [] of String + env.params.query.each do |k, v| + params << "#{k}=#{v}" + end + params = params.join("&") + + url = "/watch?v=#{item}" + if !params.empty? + url += "&#{params}" + end + + # Check if item is video ID + if item.match(/^[a-zA-Z0-9_-]{11}$/) && YT_POOL.client &.head("/watch?v=#{item}").status_code != 404 + env.response.headers["Location"] = url + haltf env, status_code: 302 + end + end + + env.response.headers["Location"] = "/" + haltf env, status_code: 302 + end +end -- cgit v1.2.3 From 848a60aa9bfca457ae6e1a470d6fcf3ef03a1f38 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 01:01:31 +0200 Subject: routes: remove useless 'locale' variable in error 505 handler --- src/invidious.cr | 1 - 1 file changed, 1 deletion(-) (limited to 'src') diff --git a/src/invidious.cr b/src/invidious.cr index aff879e3..0601d5b2 100644 --- a/src/invidious.cr +++ b/src/invidious.cr @@ -191,7 +191,6 @@ error 404 do |env| end error 500 do |env, ex| - locale = env.get("preferences").as(Preferences).locale error_template(500, ex) end -- cgit v1.2.3 From cb8a375c5e7ae79cad8daa9430b65c68f50e2885 Mon Sep 17 00:00:00 2001 From: Samantaz Fox Date: Wed, 10 Aug 2022 20:50:49 +0200 Subject: routing: Directly call Kemal's add_route function --- src/invidious/routing.cr | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/invidious/routing.cr b/src/invidious/routing.cr index 8084b3e4..b1cef086 100644 --- a/src/invidious/routing.cr +++ b/src/invidious/routing.cr @@ -4,7 +4,11 @@ module Invidious::Routing {% for http_method in {"get", "post", "delete", "options", "patch", "put"} %} macro {{http_method.id}}(path, controller, method = :handle) - {{http_method.id}} \{{ path }} do |env| + unless !Kemal::Utils.path_starts_with_slash?(\{{path}}) + raise Kemal::Exceptions::InvalidPathStartException.new({{http_method}}, \{{path}}) + end + + Kemal::RouteHandler::INSTANCE.add_route({{http_method.upcase}}, \{{path}}) do |env| \{{ controller }}.\{{ method.id }}(env) end end -- cgit v1.2.3 From bbf66c9b72de55e4803fd73b9906cc7a4429550c Mon Sep 17 00:00:00 2001 From: CalculationPaper <109677665+CalculationPaper@users.noreply.github.com> Date: Fri, 12 Aug 2022 07:58:52 +0200 Subject: Add/Change Javascript license notice --- src/invidious/views/licenses.ecr | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 25b24ed4..4e395d6d 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -23,6 +23,20 @@
    + helpers.js + + AGPL-3.0 + + <%= translate(locale, "source") %> +
    community.js @@ -169,7 +183,7 @@ - MIT + Expat @@ -253,7 +267,7 @@ - MIT + Expat -- cgit v1.2.3 From c847d6d3708532451609ec1fb2cd9d1cbf842c68 Mon Sep 17 00:00:00 2001 From: CalculationPaper <109677665+CalculationPaper@users.noreply.github.com> Date: Fri, 12 Aug 2022 19:59:35 +0200 Subject: Update licenses.ecr Oh, it's handlers not helpers. --- src/invidious/views/licenses.ecr | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/invidious/views/licenses.ecr b/src/invidious/views/licenses.ecr index 4e395d6d..667cfa37 100644 --- a/src/invidious/views/licenses.ecr +++ b/src/invidious/views/licenses.ecr @@ -25,7 +25,7 @@
    - helpers.js + handlers.js @@ -33,7 +33,7 @@ - <%= translate(locale, "source") %> + <%= translate(locale, "source") %>